Compare commits

..

10 Commits

Author SHA1 Message Date
QuentinHsu 4dd68bad52 perf(model-pricing): move pricing tabs into page title
- place the model pricing tab switcher beside the page title instead of spanning the content area.
- keep the switcher width tied to its labels while preserving spacing around title status content.
2026-06-06 15:26:53 +08:00
QuentinHsu 0f043ae404 feat(json-editor): add reusable JSON code editor
- introduce a shared themed JSON editor with line numbers, formatting, status feedback, and keyboard editing helpers.
- use the shared editor in model pricing JSON mode so pricing maps get consistent editor behavior.
- localize structured JSON validation messages so parse errors avoid browser-specific English text.
2026-06-06 15:14:26 +08:00
QuentinHsu 75c05bb4b8 perf(model-pricing): improve JSON pricing editor layout
- render pricing JSON fields from shared configuration to reduce duplicated form markup.
- use fixed-height JSON textareas so long model maps scroll internally instead of stretching the page.
- arrange JSON editors in responsive columns to make wider settings pages easier to scan.
2026-06-06 14:36:21 +08:00
QuentinHsu 81d3dc08e5 perf(model-pricing): reduce duplicate model name display 2026-06-06 14:15:44 +08:00
QuentinHsu 5681c92b3f perf(model-pricing): refine visual editor actions
- keep the global reset action in the top toolbar while moving visual-mode saves into the model editor footer.
- pin the actions header with the rest of the model table headers so horizontal scrolling keeps context visible.
- add action icons to make save and reset controls easier to scan.
2026-06-05 01:04:47 +08:00
QuentinHsu 6e5a359110 refactor(model-pricing): split visual pricing editor modules
- extract pricing form primitives, snapshot helpers, and table column setup to keep the editor components smaller.
- remove draft comparison UI now that switching models discards unsaved edits.
- refine the model list with a fixed actions column and tighter mode and price summary display.
2026-06-05 00:06:41 +08:00
QuentinHsu 77d3157592 fix(model-pricing): commit visual pricing drafts on save
- Commit the open visual editor draft before saving model pricing settings
- Show unsaved draft differences against persisted model pricing values
- Move model pricing actions into the editor toolbar and refine the visual editor layout
2026-06-04 17:22:50 +08:00
QuentinHsu 39e05118ff fix(model-pricing): align pricing mode editor spacing
- add consistent tab and field spacing so each pricing mode keeps the same visual rhythm.
- wrap per-request and tiered sections in shared field groups to match the per-token form structure.
- keep fixed-price descriptions and validation messages aligned with the updated field layout.
2026-06-03 18:27:40 +08:00
QuentinHsu 9e59ffc3d8 fix(model-pricing): align pricing mode editor spacing
- add consistent tab and field spacing so each pricing mode keeps the same visual rhythm.
- wrap per-request and tiered sections in shared field groups to match the per-token form structure.
- keep fixed-price descriptions and validation messages aligned with the updated field layout.
2026-06-03 18:27:07 +08:00
QuentinHsu abad0d3cc0 fix(model-pricing): detect visual pricing draft changes on save
- expose a draft commit handle from the model pricing editor panel before saving.
- commit the open visual editor into the parent form before page-level save runs.
- support both desktop side editor and mobile sheet save paths.
2026-06-03 14:49:08 +08:00
146 changed files with 11049 additions and 10546 deletions
-2
View File
@@ -56,8 +56,6 @@
# 对话超时设置
# 所有请求超时时间,单位秒,默认为0,表示不限制
# RELAY_TIMEOUT=0
# Relay HTTP 客户端空闲连接超时时间,单位秒,默认跟随 Go 标准库,设置为0表示不限制
# RELAY_IDLE_CONN_TIMEOUT=90
# 流模式无响应超时时间,单位秒,如果出现空补全可以尝试改为更大值
# STREAMING_TIMEOUT=300
+4 -11
View File
@@ -11,8 +11,6 @@ assignees: ''
- 文档:https://docs.newapi.ai/
- 使用问题先看或先问:https://deepwiki.com/QuantumNous/new-api
- 开启透传后的转发相关反馈不接受 issue;透传模式会直接转发请求,请自行确认上游行为。
- 不接受 coding plan、逆向渠道等技术支持类 issue。
- 警告:删除本模板、删除小节标题或随意清空内容的 issue,可能会被直接关闭;重复恶意提交者可能会被 block。
**您当前的 newapi 版本**
@@ -22,18 +20,13 @@ assignees: ''
**提交确认**
[//]: # (方框内删除已有的空格,填 x 号)
- [ ] **非重复 issue:** 我已搜索现有 [Issues](https://github.com/QuantumNous/new-api/issues?q=is%3Aissue)确认目前没有类似 issue
- [ ] **提交前必读:** 我已完整阅读上方“提交前必读”,并已查看文档 https://docs.newapi.ai/项目 README 且向 AI 提问,确认这不是使用、配置或接入类问题。
- [ ] **模板完整:** 我未删除此模板中的任何引导内容或小节标题,并会按要求完整填写
- [ ] **维护成本:** 我理解项目维护者精力有限,不遵循模板要求的 issue 可能会被无视或直接关闭
+ [ ] 我已确认目前没有类似 issue
+ [ ] 我已完整查看文档 https://docs.newapi.ai/项目 README,尤其是常见问题部分
+ [ ] 我未删除此模板中的任何引导内容或小节标题,并会按要求完整填写
+ [ ] 我理解项目维护者精力有限,不遵循模板要求的 issue 可能会被无视或直接关闭
**问题描述**
请尽可能说明问题现象、影响范围,以及你判断它是程序问题而不是上游行为或使用问题的依据。
- 转发问题请尽可能说明渠道类型、转换格式、上游原生支持依据和服务端日志。
- 计费问题请尽可能附请求返回的 `usage` 示例。
**复现步骤**
**预期结果**
+4 -11
View File
@@ -11,8 +11,6 @@ assignees: ''
- Docs: https://docs.newapi.ai/
- Usage questions first: https://deepwiki.com/QuantumNous/new-api
- Issues about forwarding behavior after enabling pass-through mode are not accepted; pass-through mode forwards requests directly, so please verify upstream behavior yourself.
- Technical support requests such as coding plans or reverse-engineering channels are not accepted as issues.
- Warning: issues with this template removed, section headings deleted, or content cleared may be closed directly. Repeated abusive submissions may result in a block.
**Your current newapi version**
@@ -22,18 +20,13 @@ Please fill this in, for example: `v1.0.0`
**Submission Checks**
[//]: # (Remove the space in the box and fill with an x)
- [ ] **Non-duplicate issue:** I have searched existing [Issues](https://github.com/QuantumNous/new-api/issues?q=is%3Aissue) and confirmed there are no similar issues.
- [ ] **Read this first:** I have fully read the section above, reviewed the docs at https://docs.newapi.ai/ and the project README, and asked AI first, confirming this is not a usage, configuration, or integration question.
- [ ] **Template intact:** I have not removed any guidance or section headings from this template and will complete it as requested.
- [ ] **Maintainer time:** I understand that maintainers have limited time and issues that do not follow this template may be ignored or closed directly.
+ [ ] I have confirmed there are no similar issues
+ [ ] I have thoroughly read the docs at https://docs.newapi.ai/ and the project README, especially the FAQ section
+ [ ] I have not removed any guidance or section headings from this template and will complete it as requested
+ [ ] I understand that maintainers have limited time and issues that do not follow this template may be ignored or closed directly
**Issue Description**
Describe the symptom, impact scope, and why you believe this is an application issue rather than upstream behavior or a usage question with as much detail as possible.
- For forwarding issues, include the channel type, conversion format, upstream native-support evidence, and server logs when possible.
- For billing issues, include an example of the returned `usage` when possible.
**Steps to Reproduce**
**Expected Result**
+4 -6
View File
@@ -11,8 +11,6 @@ assignees: ''
- 文档:https://docs.newapi.ai/
- 使用问题先看或先问:https://deepwiki.com/QuantumNous/new-api
- 开启透传后的转发相关反馈不接受 issue;透传模式会直接转发请求,请自行确认上游行为。
- 不接受 coding plan、逆向渠道等技术支持类 issue。
- 警告:删除本模板、删除小节标题或随意清空内容的 issue,可能会被直接关闭;重复恶意提交者可能会被 block。
**您当前的 newapi 版本**
@@ -22,10 +20,10 @@ assignees: ''
**提交确认**
[//]: # (方框内删除已有的空格,填 x 号)
- [ ] **非重复 issue:** 我已搜索现有 [Issues](https://github.com/QuantumNous/new-api/issues?q=is%3Aissue)确认目前没有类似 issue
- [ ] **提交前必读:** 我已完整阅读上方“提交前必读”,并已查看文档 https://docs.newapi.ai/项目 README 且向 AI 提问,确认这不是使用、配置或接入类问题,且现有版本无法满足需求
- [ ] **模板完整:** 我未删除此模板中的任何引导内容或小节标题,并会按要求完整填写
- [ ] **维护成本:** 我理解项目维护者精力有限,不遵循模板要求的 issue 可能会被无视或直接关闭
+ [ ] 我已确认目前没有类似 issue
+ [ ] 我已完整查看文档 https://docs.newapi.ai/项目 README,已确定现有版本无法满足需求
+ [ ] 我未删除此模板中的任何引导内容或小节标题,并会按要求完整填写
+ [ ] 我理解项目维护者精力有限,不遵循模板要求的 issue 可能会被无视或直接关闭
**功能描述**
+4 -6
View File
@@ -11,8 +11,6 @@ assignees: ''
- Docs: https://docs.newapi.ai/
- Usage questions first: https://deepwiki.com/QuantumNous/new-api
- Issues about forwarding behavior after enabling pass-through mode are not accepted; pass-through mode forwards requests directly, so please verify upstream behavior yourself.
- Technical support requests such as coding plans or reverse-engineering channels are not accepted as issues.
- Warning: issues with this template removed, section headings deleted, or content cleared may be closed directly. Repeated abusive submissions may result in a block.
**Your current newapi version**
@@ -22,10 +20,10 @@ Please fill this in, for example: `v1.0.0`
**Submission Checks**
[//]: # (Remove the space in the box and fill with an x)
- [ ] **Non-duplicate issue:** I have searched existing [Issues](https://github.com/QuantumNous/new-api/issues?q=is%3Aissue) and confirmed there are no similar issues.
- [ ] **Read this first:** I have fully read the section above, reviewed the docs at https://docs.newapi.ai/ and the project README, and asked AI first, confirming this is not a usage, configuration, or integration question, and that the current version cannot meet my needs.
- [ ] **Template intact:** I have not removed any guidance or section headings from this template and will complete it as requested.
- [ ] **Maintainer time:** I understand that maintainers have limited time and issues that do not follow this template may be ignored or closed directly.
+ [ ] I have confirmed there are no similar issues
+ [ ] I have thoroughly read the docs at https://docs.newapi.ai/ and the project README, and confirmed the current version cannot meet my needs
+ [ ] I have not removed any guidance or section headings from this template and will complete it as requested
+ [ ] I understand that maintainers have limited time and issues that do not follow this template may be ignored or closed directly
**Feature Description**
-1
View File
@@ -316,7 +316,6 @@ docker run --name new-api -d --restart always \
| `CRYPTO_SECRET` | Encryption secret (required for Redis) | - |
| `SQL_DSN` | Database connection string | - |
| `REDIS_CONN_STRING` | Redis connection string | - |
| `RELAY_IDLE_CONN_TIMEOUT` | Idle keep-alive timeout for relay HTTP clients, seconds. Defaults to Go standard library behavior; set `0` to disable | `90` |
| `STREAMING_TIMEOUT` | Streaming timeout (seconds) | `300` |
| `STREAM_SCANNER_MAX_BUFFER_MB` | Max per-line buffer (MB) for the stream scanner; increase when upstream sends huge image/base64 payloads | `64` |
| `MAX_REQUEST_BODY_MB` | Max request body size (MB, counted **after decompression**; prevents huge requests/zip bombs from exhausting memory). Exceeding it returns `413` | `32` |
-1
View File
@@ -170,7 +170,6 @@ var BatchUpdateInterval int
var RelayTimeout int // unit is second
var RelayIdleConnTimeout int // unit is second
var RelayMaxIdleConns int
var RelayMaxIdleConnsPerHost int
-2
View File
@@ -102,7 +102,6 @@ func InitEnv() {
SyncFrequency = GetEnvOrDefault("SYNC_FREQUENCY", 60)
BatchUpdateInterval = GetEnvOrDefault("BATCH_UPDATE_INTERVAL", 5)
RelayTimeout = GetEnvOrDefault("RELAY_TIMEOUT", 0)
RelayIdleConnTimeout = GetEnvOrDefault("RELAY_IDLE_CONN_TIMEOUT", 90)
RelayMaxIdleConns = GetEnvOrDefault("RELAY_MAX_IDLE_CONNS", 500)
RelayMaxIdleConnsPerHost = GetEnvOrDefault("RELAY_MAX_IDLE_CONNS_PER_HOST", 100)
@@ -136,7 +135,6 @@ func initConstantEnv() {
constant.StreamScannerMaxBufferMB = GetEnvOrDefault("STREAM_SCANNER_MAX_BUFFER_MB", 128)
// MaxRequestBodyMB 请求体最大大小(解压后),用于防止超大请求/zip bomb导致内存暴涨
constant.MaxRequestBodyMB = GetEnvOrDefault("MAX_REQUEST_BODY_MB", 128)
constant.AnonymousRequestBodyLimitKB = GetEnvOrDefault("ANONYMOUS_REQUEST_BODY_LIMIT_KB", 512)
// ForceStreamOption 覆盖请求参数,强制返回usage信息
constant.ForceStreamOption = GetEnvOrDefaultBool("FORCE_STREAM_OPTION", true)
constant.CountToken = GetEnvOrDefaultBool("CountToken", true)
-13
View File
@@ -1,13 +0,0 @@
package common
import "github.com/QuantumNous/new-api/constant"
const defaultAnonymousRequestBodyLimitKB = 512
func GetAnonymousRequestBodyLimitBytes() int64 {
limitKB := constant.AnonymousRequestBodyLimitKB
if limitKB < 0 {
limitKB = defaultAnonymousRequestBodyLimitKB
}
return int64(limitKB) << 10
}
-1
View File
@@ -10,7 +10,6 @@ var GetMediaToken bool
var GetMediaTokenNotStream bool
var UpdateTask bool
var MaxRequestBodyMB int
var AnonymousRequestBodyLimitKB int
var AzureDefaultAPIVersion string
var NotifyLimitCount int
var NotificationLimitDurationMinute int
+1 -1
View File
@@ -814,7 +814,7 @@ func buildTestRequest(model string, endpointType string, channel *model.Channel,
testRequest.StreamOptions = &dto.StreamOptions{IncludeUsage: true}
}
if dto.IsOpenAIReasoningOModel(model) {
if strings.HasPrefix(model, "o") {
testRequest.MaxCompletionTokens = lo.ToPtr(uint(16))
} else if strings.Contains(model, "thinking") {
if !strings.Contains(model, "claude") {
-1
View File
@@ -34,7 +34,6 @@ services:
- BATCH_UPDATE_ENABLED=true # 是否启用批量更新 (Whether to enable batch update)
- NODE_NAME=new-api-node-1 # 节点名称,用于审计日志中标识节点身份;多节点/容器部署时建议设置 (Node name used in audit logs; recommended when running multiple instances or in containers)
# - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 (Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions
# - RELAY_IDLE_CONN_TIMEOUT=90 # Relay HTTP 客户端空闲连接超时时间,单位秒,默认跟随 Go 标准库,设置为0表示不限制 (Relay HTTP client idle keep-alive timeout in seconds, defaults to Go standard library; set 0 to disable)
# - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!! (multi-node deployment, set this to a random string!!!!!!!
# - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed
# - GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX # Google Analytics 的测量 ID (Google Analytics Measurement ID)
+2 -12
View File
@@ -213,22 +213,12 @@ func (r *GeneralOpenAIRequest) ToMap() map[string]any {
return result
}
func IsOpenAIReasoningOModel(modelName string) bool {
return strings.HasPrefix(modelName, "o1") ||
strings.HasPrefix(modelName, "o3") ||
strings.HasPrefix(modelName, "o4")
}
func IsOpenAIGPT5Model(modelName string) bool {
return strings.HasPrefix(modelName, "gpt-5")
}
func (r *GeneralOpenAIRequest) GetSystemRoleName() string {
if IsOpenAIReasoningOModel(r.Model) {
if strings.HasPrefix(r.Model, "o") {
if !strings.HasPrefix(r.Model, "o1-mini") && !strings.HasPrefix(r.Model, "o1-preview") {
return "developer"
}
} else if IsOpenAIGPT5Model(r.Model) {
} else if strings.HasPrefix(r.Model, "gpt-5") {
return "developer"
}
return "system"
-24
View File
@@ -71,27 +71,3 @@ func TestOpenAIResponsesRequestPreserveExplicitZeroValues(t *testing.T) {
require.True(t, gjson.GetBytes(encoded, "stream").Exists())
require.True(t, gjson.GetBytes(encoded, "top_p").Exists())
}
func TestGeneralOpenAIRequestGetSystemRoleName(t *testing.T) {
tests := []struct {
name string
model string
want string
}{
{name: "o1 uses developer", model: "o1", want: "developer"},
{name: "o3 family uses developer", model: "o3-mini-high", want: "developer"},
{name: "o4 family uses developer", model: "o4-mini", want: "developer"},
{name: "o1 mini stays system", model: "o1-mini", want: "system"},
{name: "o1 preview stays system", model: "o1-preview", want: "system"},
{name: "gpt 5 uses developer", model: "gpt-5", want: "developer"},
{name: "omni is not o series", model: "omni-moderation-latest", want: "system"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := GeneralOpenAIRequest{Model: tt.model}
require.Equal(t, tt.want, req.GetSystemRoleName())
})
}
}
+7 -35
View File
@@ -102,10 +102,14 @@ func Distribute() func(c *gin.Context) {
}
if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found {
affinityUsable := false
preferred, err := model.CacheGetChannel(preferredChannelID)
if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled {
if usingGroup == "auto" {
if err == nil && preferred != nil {
if preferred.Status != common.ChannelStatusEnabled {
if service.ShouldSkipRetryAfterChannelAffinityFailure(c) {
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorAffinityChannelDisabled))
return
}
} else if usingGroup == "auto" {
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
autoGroups := service.GetUserAutoGroup(userGroup)
for _, g := range autoGroups {
@@ -113,7 +117,6 @@ func Distribute() func(c *gin.Context) {
selectGroup = g
common.SetContextKey(c, constant.ContextKeyAutoGroup, g)
channel = preferred
affinityUsable = true
service.MarkChannelAffinityUsed(c, g, preferred.Id)
break
}
@@ -121,13 +124,9 @@ func Distribute() func(c *gin.Context) {
} else if model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, preferred.Id) {
channel = preferred
selectGroup = usingGroup
affinityUsable = true
service.MarkChannelAffinityUsed(c, usingGroup, preferred.Id)
}
}
if !affinityUsable && !service.ShouldKeepChannelAffinityOnChannelDisabled() {
service.ClearCurrentChannelAffinityCache(c)
}
}
if channel == nil {
@@ -299,7 +298,6 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
} else if c.Request.Method == http.MethodGet {
relayMode = relayconstant.RelayModeVideoFetchByID
shouldSelectChannel = false
modelRequest.Model = getTaskOriginModelName(c)
}
c.Set("relay_mode", relayMode)
} else if strings.Contains(c.Request.URL.Path, "/v1/video/generations") {
@@ -314,7 +312,6 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
} else if c.Request.Method == http.MethodGet {
relayMode = relayconstant.RelayModeVideoFetchByID
shouldSelectChannel = false
modelRequest.Model = getTaskOriginModelName(c)
}
if _, ok := c.Get("relay_mode"); !ok {
c.Set("relay_mode", relayMode)
@@ -399,31 +396,6 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
return &modelRequest, shouldSelectChannel, nil
}
// 修复 #4834: GET /v1/video/generations/:task_id && /v1/video/:task_id 此前不解析 model
// 当 token 启用「可用模型限制」时,下游 modelLimitEnable 校验会因
// modelRequest.Model 为空而误报 "This token has no access to model"。
// 从已存储的任务记录中回填 OriginModelName 即可让校验走在正确的模型上。
func getTaskOriginModelName(c *gin.Context) string {
if !common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled) {
return ""
}
taskId := c.Param("task_id")
if taskId == "" {
// jimeng adapter
taskId = c.GetString("task_id")
}
if taskId == "" {
return ""
}
userId := c.GetInt("id")
if task, exist, err := model.GetByTaskId(userId, taskId); err == nil && exist && task != nil {
return task.Properties.OriginModelName
}
return ""
}
func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, modelName string) *types.NewAPIError {
c.Set("original_model", modelName) // for retry
if channel == nil {
-47
View File
@@ -1,47 +0,0 @@
package middleware
import (
"bytes"
"io"
"net/http"
"github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin"
)
func AnonymousRequestBodyLimit() gin.HandlerFunc {
return func(c *gin.Context) {
maxBytes := common.GetAnonymousRequestBodyLimitBytes()
if maxBytes <= 0 || c.Request.Body == nil {
c.Next()
return
}
originalBody := c.Request.Body
limitedBody, err := readAnonymousRequestBody(originalBody, maxBytes)
_ = originalBody.Close()
if err != nil {
if common.IsRequestBodyTooLargeError(err) {
c.AbortWithStatus(http.StatusRequestEntityTooLarge)
return
}
c.AbortWithStatus(http.StatusBadRequest)
return
}
c.Request.Body = io.NopCloser(bytes.NewReader(limitedBody))
c.Request.ContentLength = int64(len(limitedBody))
c.Next()
}
}
func readAnonymousRequestBody(body io.Reader, maxBytes int64) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(body, maxBytes+1))
if err != nil {
return nil, err
}
if int64(len(data)) > maxBytes {
return nil, common.ErrRequestBodyTooLarge
}
return data, nil
}
+1 -1
View File
@@ -30,7 +30,7 @@ func convertCf2CompletionsRequest(textRequest dto.GeneralOpenAIRequest) *CfReque
}
func cfStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*types.NewAPIError, *dto.Usage) {
scanner := helper.NewStreamScanner(resp.Body)
scanner := bufio.NewScanner(resp.Body)
scanner.Split(bufio.ScanLines)
helper.SetEventStreamHeaders(c)
+2 -4
View File
@@ -1,6 +1,7 @@
package cohere
import (
"bufio"
"encoding/json"
"io"
"net/http"
@@ -85,7 +86,7 @@ func cohereStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
createdTime := common.GetTimestamp()
usage := &dto.Usage{}
responseText := ""
scanner := helper.NewStreamScanner(resp.Body)
scanner := bufio.NewScanner(resp.Body)
scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
@@ -105,9 +106,6 @@ func cohereStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
data := scanner.Text()
dataChan <- data
}
if err := scanner.Err(); err != nil {
common.SysLog("error reading stream: " + err.Error())
}
stopChan <- true
}()
helper.SetEventStreamHeaders(c)
+1 -1
View File
@@ -98,7 +98,7 @@ func cozeChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Res
}
func cozeChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
scanner := helper.NewStreamScanner(resp.Body)
scanner := bufio.NewScanner(resp.Body)
scanner.Split(bufio.ScanLines)
helper.SetEventStreamHeaders(c)
id := helper.GetResponseID(c)
+3 -8
View File
@@ -159,14 +159,9 @@ func requestOpenAI2Dify(c *gin.Context, info *relaycommon.RelayInfo, request dto
media := mediaContent.GetImageMedia()
var file *DifyFile
if media.IsRemoteImage() {
// 修复 #2083: 远程图片分支此前未初始化 file,
// 导致 file.Type = ... 触发 nil pointer dereference
// 而 panic500: "invalid memory address or nil pointer dereference")。
file = &DifyFile{
Type: media.MimeType,
TransferMode: "remote_url",
URL: media.Url,
}
file.Type = media.MimeType
file.TransferMode = "remote_url"
file.URL = media.Url
} else {
file = uploadDifyFile(c, info, difyReq.User, mediaContent)
}
+2 -2
View File
@@ -1,6 +1,7 @@
package ollama
import (
"bufio"
"encoding/json"
"fmt"
"io"
@@ -11,7 +12,6 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/types"
@@ -397,7 +397,7 @@ func PullOllamaModelStream(baseURL, apiKey, modelName string, progressCallback f
}
// 读取流式响应
scanner := helper.NewStreamScanner(response.Body)
scanner := bufio.NewScanner(response.Body)
successful := false
for scanner.Scan() {
line := scanner.Text()
+2 -1
View File
@@ -1,6 +1,7 @@
package ollama
import (
"bufio"
"encoding/json"
"fmt"
"io"
@@ -69,7 +70,7 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
defer service.CloseResponseBodyGracefully(resp)
helper.SetEventStreamHeaders(c)
scanner := helper.NewStreamScanner(resp.Body)
scanner := bufio.NewScanner(resp.Body)
usage := &dto.Usage{}
var model = info.UpstreamModelName
var responseId = common.GetUUID()
+3 -5
View File
@@ -310,20 +310,18 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
}
}
isOModel := dto.IsOpenAIReasoningOModel(info.UpstreamModelName)
isGPT5Model := dto.IsOpenAIGPT5Model(info.UpstreamModelName)
if isOModel || isGPT5Model {
if strings.HasPrefix(info.UpstreamModelName, "o") || strings.HasPrefix(info.UpstreamModelName, "gpt-5") {
if lo.FromPtrOr(request.MaxCompletionTokens, uint(0)) == 0 && lo.FromPtrOr(request.MaxTokens, uint(0)) != 0 {
request.MaxCompletionTokens = request.MaxTokens
request.MaxTokens = nil
}
if isOModel {
if strings.HasPrefix(info.UpstreamModelName, "o") {
request.Temperature = nil
}
// gpt-5系列模型适配 归零不再支持的参数
if isGPT5Model {
if strings.HasPrefix(info.UpstreamModelName, "gpt-5") {
request.Temperature = nil
request.TopP = nil
request.LogProbs = nil
+1 -1
View File
@@ -92,7 +92,7 @@ func streamResponseTencent2OpenAI(TencentResponse *TencentChatResponse) *dto.Cha
func tencentStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
var responseText string
scanner := helper.NewStreamScanner(resp.Body)
scanner := bufio.NewScanner(resp.Body)
scanner.Split(bufio.ScanLines)
helper.SetEventStreamHeaders(c)
+1 -4
View File
@@ -157,7 +157,7 @@ func streamMetaResponseZhipu2OpenAI(zhipuResponse *ZhipuStreamMetaResponse) (*dt
func zhipuStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
var usage *dto.Usage
scanner := helper.NewStreamScanner(resp.Body)
scanner := bufio.NewScanner(resp.Body)
scanner.Split(bufio.ScanLines)
dataChan := make(chan string)
metaChan := make(chan string)
@@ -180,9 +180,6 @@ func zhipuStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.
}
}
}
if err := scanner.Err(); err != nil {
common.SysLog("error reading stream: " + err.Error())
}
stopChan <- true
}()
helper.SetEventStreamHeaders(c)
-1
View File
@@ -155,7 +155,6 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
if err != nil {
return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
}
info.UpstreamRequestBodySize = storage.Size()
requestBody = common.ReaderOnly(storage)
} else {
convertedRequest, err := adaptor.ConvertClaudeRequest(c, info, request)
+2 -7
View File
@@ -34,12 +34,6 @@ func getScannerBufferSize() int {
return DefaultMaxScannerBufferSize
}
func NewStreamScanner(reader io.Reader) *bufio.Scanner {
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, InitialScannerBufferSize), getScannerBufferSize())
return scanner
}
func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, dataHandler func(data string, sr *StreamResult)) {
if resp == nil || dataHandler == nil {
@@ -60,7 +54,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
var (
stopChan = make(chan bool, 3) // 增加缓冲区避免阻塞
scanner = NewStreamScanner(resp.Body)
scanner = bufio.NewScanner(resp.Body)
ticker = time.NewTicker(streamingTimeout)
pingTicker *time.Ticker
writeMutex sync.Mutex // Mutex to protect concurrent writes
@@ -110,6 +104,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
close(stopChan)
}()
scanner.Buffer(make([]byte, InitialScannerBufferSize), getScannerBufferSize())
scanner.Split(bufio.ScanLines)
SetEventStreamHeaders(c)
-17
View File
@@ -1,7 +1,6 @@
package helper
import (
"bufio"
"fmt"
"io"
"net/http"
@@ -82,22 +81,6 @@ func TestStreamScannerHandler_NilInputs(t *testing.T) {
StreamScannerHandler(c, &http.Response{Body: io.NopCloser(strings.NewReader(""))}, info, nil)
}
func TestNewStreamScanner_AllowsLargeStreamLine(t *testing.T) {
oldBufferMB := constant.StreamScannerMaxBufferMB
constant.StreamScannerMaxBufferMB = 1
t.Cleanup(func() {
constant.StreamScannerMaxBufferMB = oldBufferMB
})
payload := strings.Repeat("x", 128<<10)
scanner := NewStreamScanner(strings.NewReader("data: " + payload + "\n"))
scanner.Split(bufio.ScanLines)
require.True(t, scanner.Scan())
assert.Equal(t, "data: "+payload, scanner.Text())
require.NoError(t, scanner.Err())
}
func TestStreamScannerHandler_EmptyBody(t *testing.T) {
t.Parallel()
+16 -17
View File
@@ -17,10 +17,9 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.Use(gzip.Gzip(gzip.DefaultCompression))
apiRouter.Use(middleware.BodyStorageCleanup()) // 清理请求体存储
apiRouter.Use(middleware.GlobalAPIRateLimit())
anonymousRequestBodyLimit := middleware.AnonymousRequestBodyLimit()
{
apiRouter.GET("/setup", controller.GetSetup)
apiRouter.POST("/setup", anonymousRequestBodyLimit, controller.PostSetup)
apiRouter.POST("/setup", controller.PostSetup)
apiRouter.GET("/status", controller.GetStatus)
apiRouter.GET("/uptime/status", controller.GetUptimeKumaStatus)
apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels)
@@ -41,39 +40,39 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.GET("/rankings", middleware.HeaderNavModuleAuth("rankings"), controller.GetRankings)
apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)
apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, controller.ResetPassword)
apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword)
// OAuth routes - specific routes must come before :provider wildcard
apiRouter.GET("/oauth/state", middleware.CriticalRateLimit(), controller.GenerateOAuthCode)
apiRouter.POST("/oauth/email/bind", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, controller.EmailBind)
apiRouter.POST("/oauth/email/bind", middleware.CriticalRateLimit(), controller.EmailBind)
// Non-standard OAuth (WeChat, Telegram) - keep original routes
apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth)
apiRouter.POST("/oauth/wechat/bind", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, controller.WeChatBind)
apiRouter.POST("/oauth/wechat/bind", middleware.CriticalRateLimit(), controller.WeChatBind)
apiRouter.GET("/oauth/telegram/login", middleware.CriticalRateLimit(), controller.TelegramLogin)
apiRouter.GET("/oauth/telegram/bind", middleware.CriticalRateLimit(), controller.TelegramBind)
// Standard OAuth providers (GitHub, Discord, OIDC, LinuxDO) - unified route
apiRouter.GET("/oauth/:provider", middleware.CriticalRateLimit(), controller.HandleOAuth)
apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig)
apiRouter.POST("/stripe/webhook", anonymousRequestBodyLimit, controller.StripeWebhook)
apiRouter.POST("/creem/webhook", anonymousRequestBodyLimit, controller.CreemWebhook)
apiRouter.POST("/waffo/webhook", anonymousRequestBodyLimit, controller.WaffoWebhook)
apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
apiRouter.POST("/creem/webhook", controller.CreemWebhook)
apiRouter.POST("/waffo/webhook", controller.WaffoWebhook)
// :env separates test vs prod URLs so the operator can register each
// in Pancake's matching webhook slot; handler enforces env match.
apiRouter.POST("/waffo-pancake/webhook/:env", anonymousRequestBodyLimit, controller.WaffoPancakeWebhook)
apiRouter.POST("/waffo-pancake/webhook/:env", controller.WaffoPancakeWebhook)
// Universal secure verification routes
apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify)
userRoute := apiRouter.Group("/user")
{
userRoute.POST("/register", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, middleware.TurnstileCheck(), controller.Register)
userRoute.POST("/login", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, middleware.TurnstileCheck(), controller.Login)
userRoute.POST("/login/2fa", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, controller.Verify2FALogin)
userRoute.POST("/passkey/login/begin", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, controller.PasskeyLoginBegin)
userRoute.POST("/passkey/login/finish", middleware.CriticalRateLimit(), anonymousRequestBodyLimit, controller.PasskeyLoginFinish)
userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register)
userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Login)
userRoute.POST("/login/2fa", middleware.CriticalRateLimit(), controller.Verify2FALogin)
userRoute.POST("/passkey/login/begin", middleware.CriticalRateLimit(), controller.PasskeyLoginBegin)
userRoute.POST("/passkey/login/finish", middleware.CriticalRateLimit(), controller.PasskeyLoginFinish)
//userRoute.POST("/tokenlog", middleware.CriticalRateLimit(), controller.TokenLog)
userRoute.GET("/logout", controller.Logout)
userRoute.POST("/epay/notify", anonymousRequestBodyLimit, controller.EpayNotify)
userRoute.POST("/epay/notify", controller.EpayNotify)
userRoute.GET("/epay/notify", controller.EpayNotify)
userRoute.GET("/groups", controller.GetUserGroups)
@@ -177,10 +176,10 @@ func SetApiRouter(router *gin.Engine) {
}
// Subscription payment callbacks (no auth)
apiRouter.POST("/subscription/epay/notify", anonymousRequestBodyLimit, controller.SubscriptionEpayNotify)
apiRouter.POST("/subscription/epay/notify", controller.SubscriptionEpayNotify)
apiRouter.GET("/subscription/epay/notify", controller.SubscriptionEpayNotify)
apiRouter.GET("/subscription/epay/return", controller.SubscriptionEpayReturn)
apiRouter.POST("/subscription/epay/return", anonymousRequestBodyLimit, controller.SubscriptionEpayReturn)
apiRouter.POST("/subscription/epay/return", controller.SubscriptionEpayReturn)
optionRoute := apiRouter.Group("/option")
optionRoute.Use(middleware.RootAuth())
{
-32
View File
@@ -641,38 +641,6 @@ func ShouldSkipRetryAfterChannelAffinityFailure(c *gin.Context) bool {
return meta.SkipRetry
}
func ClearCurrentChannelAffinityCache(c *gin.Context) bool {
if c == nil {
return false
}
cacheKey, _, ok := getChannelAffinityContext(c)
if !ok || cacheKey == "" {
return false
}
cache := getChannelAffinityCache()
deleted, err := cache.DeleteMany([]string{cacheKey})
if err != nil {
common.SysError(fmt.Sprintf("channel affinity cache delete current failed: err=%v", err))
return false
}
c.Set(ginKeyChannelAffinitySkipRetry, false)
for _, ok := range deleted {
if ok {
return true
}
}
return false
}
func ShouldKeepChannelAffinityOnChannelDisabled() bool {
setting := operation_setting.GetChannelAffinitySetting()
if setting == nil {
return false
}
return setting.KeepOnChannelDisabled
}
func MarkChannelAffinityUsed(c *gin.Context, selectedGroup string, channelID int) {
if c == nil || channelID <= 0 {
return
-27
View File
@@ -236,33 +236,6 @@ func TestGetPreferredChannelByAffinity_RequestHeaderKeySource(t *testing.T) {
require.Equal(t, buildChannelAffinityKeyHint(affinityValue), meta.KeyHint)
}
func TestClearCurrentChannelAffinityCache(t *testing.T) {
gin.SetMode(gin.TestMode)
cacheKeySuffix := fmt.Sprintf("codex cli trace:default:clear-current-%d", time.Now().UnixNano())
cacheKeyFull := channelAffinityCacheNamespace + ":" + cacheKeySuffix
cache := getChannelAffinityCache()
require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9527, time.Minute))
t.Cleanup(func() {
_, _ = cache.DeleteMany([]string{cacheKeySuffix})
})
ctx := buildChannelAffinityTemplateContextForTest(channelAffinityMeta{
CacheKey: cacheKeyFull,
TTLSeconds: 60,
RuleName: "codex cli trace",
SkipRetry: true,
})
require.True(t, ShouldSkipRetryAfterChannelAffinityFailure(ctx))
deleted := ClearCurrentChannelAffinityCache(ctx)
require.True(t, deleted)
_, found, err := cache.Get(cacheKeySuffix)
require.NoError(t, err)
require.False(t, found)
require.False(t, ShouldSkipRetryAfterChannelAffinityFailure(ctx))
}
func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) {
gin.SetMode(gin.TestMode)
-3
View File
@@ -37,7 +37,6 @@ func InitHttpClient() {
transport := &http.Transport{
MaxIdleConns: common.RelayMaxIdleConns,
MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost,
IdleConnTimeout: time.Duration(common.RelayIdleConnTimeout) * time.Second,
ForceAttemptHTTP2: true,
Proxy: http.ProxyFromEnvironment, // Support HTTP_PROXY, HTTPS_PROXY, NO_PROXY env vars
}
@@ -109,7 +108,6 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
transport := &http.Transport{
MaxIdleConns: common.RelayMaxIdleConns,
MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost,
IdleConnTimeout: time.Duration(common.RelayIdleConnTimeout) * time.Second,
ForceAttemptHTTP2: true,
Proxy: http.ProxyURL(parsedURL),
}
@@ -149,7 +147,6 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
transport := &http.Transport{
MaxIdleConns: common.RelayMaxIdleConns,
MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost,
IdleConnTimeout: time.Duration(common.RelayIdleConnTimeout) * time.Second,
ForceAttemptHTTP2: true,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.Dial(network, addr)
@@ -28,12 +28,11 @@ type ChannelAffinityRule struct {
}
type ChannelAffinitySetting struct {
Enabled bool `json:"enabled"`
SwitchOnSuccess bool `json:"switch_on_success"`
KeepOnChannelDisabled bool `json:"keep_on_channel_disabled"`
MaxEntries int `json:"max_entries"`
DefaultTTLSeconds int `json:"default_ttl_seconds"`
Rules []ChannelAffinityRule `json:"rules"`
Enabled bool `json:"enabled"`
SwitchOnSuccess bool `json:"switch_on_success"`
MaxEntries int `json:"max_entries"`
DefaultTTLSeconds int `json:"default_ttl_seconds"`
Rules []ChannelAffinityRule `json:"rules"`
}
var codexCliPassThroughHeaders = []string{
@@ -75,11 +74,10 @@ func buildPassHeaderTemplate(headers []string) map[string]interface{} {
}
var channelAffinitySetting = ChannelAffinitySetting{
Enabled: true,
SwitchOnSuccess: true,
KeepOnChannelDisabled: false,
MaxEntries: 100_000,
DefaultTTLSeconds: 3600,
Enabled: true,
SwitchOnSuccess: true,
MaxEntries: 100_000,
DefaultTTLSeconds: 3600,
Rules: []ChannelAffinityRule{
{
Name: "codex cli trace",
+20 -6
View File
@@ -1068,17 +1068,31 @@ export function getQuotaWithUnit(quota, digits = 6) {
return (quota / quotaPerUnit).toFixed(digits);
}
// amount 为系统内部的美元值
export function renderQuotaWithAmount(amount) {
const { symbol, rate, type } = getCurrencyConfig();
if (type === 'TOKENS') {
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
if (quotaDisplayType === 'TOKENS') {
return renderNumber(renderUnitWithQuota(amount));
}
const numericAmount = Number(amount);
if (!Number.isFinite(numericAmount)) {
return symbol + amount;
const formattedAmount = Number.isFinite(numericAmount)
? numericAmount.toFixed(2)
: amount;
if (quotaDisplayType === 'CNY') {
return '¥' + formattedAmount;
} else if (quotaDisplayType === 'CUSTOM') {
const statusStr = localStorage.getItem('status');
let symbol = '¤';
try {
if (statusStr) {
const s = JSON.parse(statusStr);
symbol = s?.custom_currency_symbol || symbol;
}
} catch (e) {}
return symbol + formattedAmount;
}
return symbol + (numericAmount * rate).toFixed(2);
return '$' + formattedAmount;
}
/**
-2
View File
@@ -1197,7 +1197,6 @@
"套餐的基本信息和定价": "Basic plan info and pricing",
"如:大带宽批量分析图片推荐": "e.g. Large bandwidth batch analysis of image recommendations",
"如:香港线路": "e.g. Hong Kong line",
"开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。": "When enabled, keep the affinity entry even if the affinity channel is disabled or no longer usable for the current group/model. When disabled, the entry will be deleted and another channel will be selected.",
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "If the affinity channel fails, after a successful retry on another channel, the affinity will be updated to the successful channel.",
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "If you are connecting to upstream One API or New API forwarding projects, please use OpenAI type. Do not use this type unless you know what you are doing.",
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "If the user request contains a system prompt, this setting will be appended to the user's system prompt",
@@ -1580,7 +1579,6 @@
"成功": "Success",
"成功兑换额度:": "Successful redemption amount:",
"成功后切换亲和": "Switch Affinity on Success",
"渠道禁用后保留亲和": "Keep Affinity When Channel Is Disabled",
"成功时自动启用通道": "Enable channel when successful",
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "I have understood that disabling two-factor authentication will permanently delete all related settings and backup codes, this operation cannot be undone",
"我已阅读并同意": "I have read and agree to",
-2
View File
@@ -1193,7 +1193,6 @@
"套餐的基本信息和定价": "Informations de base et tarification du plan",
"如:大带宽批量分析图片推荐": "par exemple, Recommandations d'analyse d'images par lots à large bande passante",
"如:香港线路": "par exemple, Ligne de Hong Kong",
"开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。": "Lorsque cette option est activée, conserver l'entrée d'affinité même si le canal d'affinité est désactivé ou n'est plus utilisable pour le groupe/modèle actuel. Lorsqu'elle est désactivée, l'entrée sera supprimée et un autre canal sera sélectionné.",
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "Si le canal d'affinité échoue, après une nouvelle tentative réussie sur un autre canal, l'affinité sera mise à jour vers le canal réussi.",
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "Si vous vous connectez à des projets de redirection One API ou New API en amont, veuillez utiliser le type OpenAI. N'utilisez pas ce type, sauf si vous savez ce que vous faites.",
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "Si la requête de l'utilisateur contient un prompt système, utilisez ce paramètre pour le concaténer avant le prompt système de l'utilisateur",
@@ -1585,7 +1584,6 @@
"成功": "Succès",
"成功兑换额度:": "Montant de l'échange réussi :",
"成功后切换亲和": "Changer l'affinité en cas de succès",
"渠道禁用后保留亲和": "Conserver l'affinité lorsque le canal est désactivé",
"成功时自动启用通道": "Activer le canal en cas de succès",
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "J'ai compris que la désactivation de l'authentification à deux facteurs supprimera définitivement tous les paramètres et codes de sauvegarde associés, cette opération ne peut pas être annulée",
"我已阅读并同意": "J'ai lu et j'accepte",
-2
View File
@@ -1180,7 +1180,6 @@
"套餐的基本信息和定价": "プランの基本情報と価格",
"如:大带宽批量分析图片推荐": "例:広帯域での画像一括分析に推奨",
"如:香港线路": "例:香港回線",
"开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。": "有効にすると、アフィニティチャネルが無効化された、または現在のグループ/モデルで利用できなくなった場合でも、そのアフィニティエントリを保持します。無効にすると、エントリを削除して別のチャネルを選択します。",
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "アフィニティチャネルが失敗した場合、別のチャネルでリトライが成功すると、アフィニティが成功したチャネルに更新されます。",
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "New APIなどのリレープロジェクトに接続する場合は、OpenAIタイプを利用してください。設定内容を熟知している場合を除き、このタイプは利用しないでください",
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "ユーザーリクエストにシステムプロンプトが含まれている場合、この設定内容がユーザーのシステムプロンプトの前に追加されます",
@@ -1556,7 +1555,6 @@
"成功": "成功",
"成功兑换额度:": "引き換え額:",
"成功后切换亲和": "成功時にアフィニティを切り替え",
"渠道禁用后保留亲和": "チャネル無効時にアフィニティを保持",
"成功时自动启用通道": "成功時にチャネルを自動的に有効にする",
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "2要素認証を無効にすると、すべての関連設定とバックアップコードが永久に削除され、この操作は元に戻すことができないことを理解しました",
"我已阅读并同意": "読んで同意します",
-2
View File
@@ -1201,7 +1201,6 @@
"套餐的基本信息和定价": "Основная информация и цена плана",
"如:大带宽批量分析图片推荐": "Например: рекомендуется для пакетного анализа изображений с большой пропускной способностью",
"如:香港线路": "Например: Гонконгская линия",
"开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。": "Если включено, запись аффинити сохраняется, даже когда канал аффинити отключён или больше не подходит для текущей группы/модели. Если выключено, запись будет удалена и выбран другой канал.",
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "Если канал аффинити не сработал, после успешного повтора на другом канале аффинити будет обновлена на успешный канал.",
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "Если вы интегрируетесь с восходящими проектами пересылки, такими как One API или New API, используйте тип OpenAI, не используйте этот тип, если вы не знаете, что делаете.",
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "Если запрос пользователя содержит системный промпт, используйте эту настройку для добавления перед системным промптом пользователя",
@@ -1603,7 +1602,6 @@
"成功": "Успешно",
"成功兑换额度:": "Успешно обменяно квота: ",
"成功后切换亲和": "Переключить аффинити при успехе",
"渠道禁用后保留亲和": "Сохранять аффинити при отключении канала",
"成功时自动启用通道": "Автоматически включать канал при успехе",
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "Я понимаю, что отключение двухфакторной аутентификации приведет к постоянному удалению всех связанных настроек и резервных кодов, и эта операция не может быть отменена",
"我已阅读并同意": "Я прочитал(а) и согласен(на)",
-2
View File
@@ -1181,7 +1181,6 @@
"套餐的基本信息和定价": "Thông tin cơ bản và giá của gói",
"如:大带宽批量分析图片推荐": "ví dụ: Phân tích hàng loạt băng thông lớn đề xuất hình ảnh",
"如:香港线路": "ví dụ: Tuyến Hồng Kông",
"开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。": "Khi bật, giữ mục ưu ái ngay cả khi kênh ưu ái bị tắt hoặc không còn dùng được cho nhóm/mô hình hiện tại. Khi tắt, mục đó sẽ bị xóa và kênh khác sẽ được chọn.",
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "Nếu kênh ưu ái thất bại, sau khi thử lại thành công trên kênh khác, ưu ái sẽ được cập nhật sang kênh thành công.",
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "Nếu bạn đang kết nối với các dự án chuyển tiếp One API hoặc New API thượng nguồn, vui lòng sử dụng loại OpenAI. Đừng sử dụng loại này trừ khi bạn biết mình đang làm gì.",
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "Nếu yêu cầu của người dùng chứa từ nhắc hệ thống, cài đặt này sẽ được nối vào trước từ nhắc hệ thống của người dùng",
@@ -1557,7 +1556,6 @@
"成功": "Thành công",
"成功兑换额度:": "Số tiền đổi thành công:",
"成功后切换亲和": "Chuyển ưu ái khi thành công",
"渠道禁用后保留亲和": "Giữ ưu ái khi kênh bị tắt",
"成功时自动启用通道": "Bật kênh khi thành công",
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "Tôi đã hiểu rằng việc vô hiệu hóa xác thực hai yếu tố sẽ xóa vĩnh viễn tất cả các cài đặt liên quan và mã dự phòng, thao tác này không thể hoàn tác",
"我已阅读并同意": "Tôi đã đọc và đồng ý với",
-2
View File
@@ -1170,7 +1170,6 @@
"套餐的基本信息和定价": "套餐的基本信息和定价",
"如:大带宽批量分析图片推荐": "如:大带宽批量分析图片推荐",
"如:香港线路": "如:香港线路",
"开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。": "开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。",
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。",
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。",
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面",
@@ -1542,7 +1541,6 @@
"成功": "成功",
"成功兑换额度:": "成功兑换额度:",
"成功后切换亲和": "成功后切换亲和",
"渠道禁用后保留亲和": "渠道禁用后保留亲和",
"成功时自动启用通道": "成功时自动启用通道",
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销",
"我已阅读并同意": "我已阅读并同意",
-2
View File
@@ -1179,7 +1179,6 @@
"套餐的基本信息和定价": "訂閱的基本資訊和定價",
"如:大带宽批量分析图片推荐": "如:大頻寬批量分析圖片推薦",
"如:香港线路": "如:香港線路",
"开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。": "開啟後,親和到的渠道被停用,或不再適用於目前分組/模型時,仍保留這條親和;關閉時會刪除並重新選擇渠道。",
"如果亲和到的渠道失败,重试到其他渠道成功后,将亲和更新到成功的渠道。": "",
"如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "如果你對接的是上游One API或者New API等轉發項目,請使用OpenAI類型,不要使用此類型,除非你知道你在做什麼。",
"如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面": "如果使用者請求中包含系統提示詞,則使用此設定拼接到使用者的系統提示詞前面",
@@ -1552,7 +1551,6 @@
"成功": "成功",
"成功兑换额度:": "成功兌換額度:",
"成功后切换亲和": "",
"渠道禁用后保留亲和": "渠道停用後保留親和",
"成功时自动启用通道": "成功時自動啟用通道",
"我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销": "我已瞭解禁用兩步驗證將永久刪除所有相關設定和備用碼,此操作不可撤銷",
"我已阅读并同意": "我已閱讀並同意",
+1 -1
View File
@@ -208,7 +208,7 @@ export default function SettingGlobalModel(props) {
<Row>
<Col span={24}>
<Form.TextArea
label={t('不自动处理思考后缀的模型列表')}
label={t('禁用思考处理的模型列表')}
field={'global.thinking_model_blacklist'}
placeholder={t('例如:') + '\n' + thinkingExample}
rows={4}
@@ -62,8 +62,6 @@ import ParamOverrideEditorModal from '../../../components/table/channels/modals/
const KEY_ENABLED = 'channel_affinity_setting.enabled';
const KEY_SWITCH_ON_SUCCESS = 'channel_affinity_setting.switch_on_success';
const KEY_KEEP_ON_CHANNEL_DISABLED =
'channel_affinity_setting.keep_on_channel_disabled';
const KEY_MAX_ENTRIES = 'channel_affinity_setting.max_entries';
const KEY_DEFAULT_TTL = 'channel_affinity_setting.default_ttl_seconds';
const KEY_RULES = 'channel_affinity_setting.rules';
@@ -243,7 +241,6 @@ export default function SettingsChannelAffinity(props) {
const [inputs, setInputs] = useState({
[KEY_ENABLED]: false,
[KEY_SWITCH_ON_SUCCESS]: true,
[KEY_KEEP_ON_CHANNEL_DISABLED]: false,
[KEY_MAX_ENTRIES]: 100000,
[KEY_DEFAULT_TTL]: 3600,
[KEY_RULES]: '[]',
@@ -861,7 +858,6 @@ export default function SettingsChannelAffinity(props) {
![
KEY_ENABLED,
KEY_SWITCH_ON_SUCCESS,
KEY_KEEP_ON_CHANNEL_DISABLED,
KEY_MAX_ENTRIES,
KEY_DEFAULT_TTL,
KEY_RULES,
@@ -872,8 +868,6 @@ export default function SettingsChannelAffinity(props) {
currentInputs[key] = toBoolean(props.options[key]);
else if (key === KEY_SWITCH_ON_SUCCESS)
currentInputs[key] = toBoolean(props.options[key]);
else if (key === KEY_KEEP_ON_CHANNEL_DISABLED)
currentInputs[key] = toBoolean(props.options[key]);
else if (key === KEY_MAX_ENTRIES)
currentInputs[key] = Number(props.options[key] || 0) || 0;
else if (key === KEY_DEFAULT_TTL)
@@ -1009,25 +1003,6 @@ export default function SettingsChannelAffinity(props) {
)}
</Text>
</Col>
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
<Form.Switch
field={KEY_KEEP_ON_CHANNEL_DISABLED}
label={t('渠道禁用后保留亲和')}
checkedText='|'
uncheckedText='O'
onChange={(value) =>
setInputs({
...inputs,
[KEY_KEEP_ON_CHANNEL_DISABLED]: value,
})
}
/>
<Text type='tertiary' size='small'>
{t(
'开启后,亲和到的渠道被禁用,或不再适用于当前分组/模型时,仍保留这条亲和;关闭时会删除并重新选择渠道。',
)}
</Text>
</Col>
</Row>
<Divider style={{ marginTop: 12, marginBottom: 12 }} />
-127
View File
@@ -1,127 +0,0 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import * as React from 'react'
import { cn } from '@/lib/utils'
import {
Dialog as DialogRoot,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
type DialogProps = React.ComponentProps<typeof DialogRoot> & {
title: React.ReactNode
description?: React.ReactNode
children: React.ReactNode
trigger?: React.ReactElement
footer?: React.ReactNode
contentHeight?: React.CSSProperties['height']
contentClassName?: string
headerClassName?: string
titleClassName?: string
descriptionClassName?: string
bodyClassName?: string
footerClassName?: string
initialFocus?: boolean
showCloseButton?: boolean
}
const dialogContentMotionClassName =
'data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 duration-100'
export function Dialog({
title,
description,
children,
trigger,
footer,
contentHeight = 'auto',
contentClassName,
headerClassName,
titleClassName,
descriptionClassName,
bodyClassName,
footerClassName,
initialFocus,
showCloseButton,
...dialogProps
}: DialogProps) {
return (
<DialogRoot {...dialogProps}>
{trigger ? <DialogTrigger render={trigger} /> : null}
<DialogContent
className={cn(
'flex max-h-[calc(100vh-2rem)] w-full flex-col gap-4 overflow-hidden p-4 sm:max-w-2xl sm:p-6',
contentClassName,
dialogContentMotionClassName
)}
initialFocus={initialFocus}
showCloseButton={showCloseButton}
style={
{
'--dialog-content-height': contentHeight,
} as React.CSSProperties
}
>
<DialogHeader
className={cn('flex-shrink-0 text-start', headerClassName)}
>
<DialogTitle className={titleClassName}>{title}</DialogTitle>
{description ? (
<DialogDescription className={descriptionClassName}>
{description}
</DialogDescription>
) : null}
</DialogHeader>
<div
className={cn(
'-mx-1 min-h-0 overflow-x-hidden overflow-y-auto overscroll-contain',
'h-[var(--dialog-content-height)] max-h-[calc(100vh-14rem)]'
)}
>
<div
className={cn(
'min-w-0 px-1 py-1',
'[&_form]:overflow-x-visible',
'[&_[data-slot=scroll-area-viewport]]:px-1 [&_[data-slot=scroll-area-viewport]]:py-1',
bodyClassName
)}
>
{children}
</div>
</div>
{footer ? (
<DialogFooter
className={cn(
'flex-shrink-0 gap-2 sm:-mx-6 sm:-mb-6 sm:justify-end sm:p-6',
footerClassName
)}
>
{footer}
</DialogFooter>
) : null}
</DialogContent>
</DialogRoot>
)
}
+284
View File
@@ -0,0 +1,284 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import {
useMemo,
useRef,
useState,
type ComponentProps,
type KeyboardEvent,
} from 'react'
import { AlertCircle, Braces, CheckCircle2, Code2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
export type JsonCodeEditorProps = Omit<ComponentProps<'div'>, 'onChange'> & {
value: string
onChange: (value: string) => void
disabled?: boolean
heightClassName?: string
}
export function JsonCodeEditor({
value,
onChange,
disabled,
heightClassName = 'h-56 min-h-56 max-h-56',
className,
id,
'aria-describedby': ariaDescribedBy,
'aria-invalid': ariaInvalid,
...rootProps
}: JsonCodeEditorProps) {
const { t } = useTranslation()
const textareaRef = useRef<HTMLTextAreaElement>(null)
const [scrollTop, setScrollTop] = useState(0)
const lineNumbers = useMemo(() => {
const count = Math.max(1, value.split('\n').length)
return Array.from({ length: count }, (_, index) => index + 1)
}, [value])
const jsonStatus = useMemo(() => {
const trimmed = value.trim()
if (!trimmed) return { valid: true, message: t('JSON') }
try {
JSON.parse(trimmed)
return { valid: true, message: t('JSON') }
} catch {
return { valid: false, message: t('Invalid JSON') }
}
}, [value, t])
const formatJson = () => {
const trimmed = value.trim()
if (!trimmed) return
try {
onChange(JSON.stringify(JSON.parse(trimmed), null, 2))
} catch {
// Keep invalid drafts untouched; validation feedback remains visible.
}
}
const updateValueWithSelection = (
nextValue: string,
selectionStart: number,
selectionEnd = selectionStart
) => {
onChange(nextValue)
window.requestAnimationFrame(() => {
textareaRef.current?.setSelectionRange(selectionStart, selectionEnd)
})
}
const getLineIndent = (text: string, cursor: number) => {
const lineStart = text.lastIndexOf('\n', cursor - 1) + 1
return text.slice(lineStart, cursor).match(/^\s*/)?.[0] ?? ''
}
const handleEditorKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
const target = event.currentTarget
const start = target.selectionStart
const end = target.selectionEnd
const selected = value.slice(start, end)
const before = value.slice(0, start)
const after = value.slice(end)
if (event.key === 'Tab') {
event.preventDefault()
if (start !== end && selected.includes('\n')) {
const selectionLineStart = value.lastIndexOf('\n', start - 1) + 1
const selectedBlock = value.slice(selectionLineStart, end)
const lines = selectedBlock.split('\n')
const nextBlock = event.shiftKey
? lines
.map((line) =>
line.startsWith(' ')
? line.slice(2)
: line.startsWith('\t')
? line.slice(1)
: line
)
.join('\n')
: lines.map((line) => ` ${line}`).join('\n')
const nextValue =
value.slice(0, selectionLineStart) + nextBlock + value.slice(end)
updateValueWithSelection(
nextValue,
selectionLineStart,
selectionLineStart + nextBlock.length
)
return
}
if (event.shiftKey) {
const lineStart = value.lastIndexOf('\n', start - 1) + 1
const removable = value.slice(lineStart, lineStart + 2)
if (removable === ' ') {
updateValueWithSelection(
value.slice(0, lineStart) + value.slice(lineStart + 2),
Math.max(lineStart, start - 2),
Math.max(lineStart, end - 2)
)
}
return
}
updateValueWithSelection(`${before} ${after}`, start + 2)
return
}
if (event.key === 'Enter') {
event.preventDefault()
const indent = getLineIndent(value, start)
const previousChar = before.trimEnd().at(-1)
const nextChar = after.trimStart().at(0)
const shouldNest = previousChar === '{' || previousChar === '['
const shouldClose =
(previousChar === '{' && nextChar === '}') ||
(previousChar === '[' && nextChar === ']')
if (shouldNest && shouldClose) {
const innerIndent = `${indent} `
const insert = `\n${innerIndent}\n${indent}`
updateValueWithSelection(
`${before}${insert}${after}`,
start + 1 + innerIndent.length
)
return
}
const nextIndent = shouldNest ? `${indent} ` : indent
const insert = `\n${nextIndent}`
updateValueWithSelection(
`${before}${insert}${after}`,
start + insert.length
)
return
}
const pairs: Record<string, string> = {
'"': '"',
'{': '}',
'[': ']',
}
const closingChars = new Set(Object.values(pairs))
if (closingChars.has(event.key) && value[start] === event.key) {
event.preventDefault()
textareaRef.current?.setSelectionRange(start + 1, start + 1)
return
}
if (pairs[event.key]) {
event.preventDefault()
const close = pairs[event.key]
const wrapped = `${event.key}${selected}${close}`
updateValueWithSelection(
`${before}${wrapped}${after}`,
start + 1,
start + 1 + selected.length
)
return
}
if (event.key === 'Backspace' && start === end && start > 0) {
const previousChar = value[start - 1]
const nextChar = value[start]
if (pairs[previousChar] === nextChar) {
event.preventDefault()
updateValueWithSelection(
value.slice(0, start - 1) + value.slice(start + 1),
start - 1
)
}
}
}
return (
<div
className={cn(
'border-input bg-background focus-within:border-ring focus-within:ring-ring/50 overflow-hidden rounded-lg border transition-colors focus-within:ring-3',
className
)}
{...rootProps}
>
<div className='bg-muted/30 flex h-8 items-center justify-between border-b px-2'>
<div className='text-muted-foreground flex min-w-0 items-center gap-1.5 text-xs font-medium'>
<Braces className='h-3.5 w-3.5' />
<span>{t('JSON')}</span>
</div>
<div className='flex items-center gap-2'>
<span
className={cn(
'flex items-center gap-1 text-xs',
jsonStatus.valid ? 'text-emerald-600' : 'text-destructive'
)}
>
{jsonStatus.valid ? (
<CheckCircle2 className='h-3.5 w-3.5' />
) : (
<AlertCircle className='h-3.5 w-3.5' />
)}
{jsonStatus.message}
</span>
<Button
type='button'
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={formatJson}
disabled={disabled || !jsonStatus.valid || !value.trim()}
>
<Code2 className='mr-1 h-3.5 w-3.5' />
{t('Format JSON')}
</Button>
</div>
</div>
<div className={cn('relative flex overflow-hidden', heightClassName)}>
<div className='bg-muted/20 text-muted-foreground/70 relative w-10 shrink-0 overflow-hidden border-r font-mono text-xs leading-5 select-none'>
<div
className='px-2 py-2 text-right'
style={{ transform: `translateY(-${scrollTop}px)` }}
>
{lineNumbers.map((lineNumber) => (
<div key={lineNumber}>{lineNumber}</div>
))}
</div>
</div>
<Textarea
ref={textareaRef}
id={id}
aria-describedby={ariaDescribedBy}
aria-invalid={ariaInvalid}
value={value}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
onKeyDown={handleEditorKeyDown}
onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
className={cn(
'[field-sizing:fixed] resize-none overflow-auto rounded-none border-0 bg-transparent px-3 py-2 font-mono text-xs leading-5 shadow-none ring-0 outline-none focus-visible:ring-0',
heightClassName
)}
spellCheck={false}
/>
</div>
</div>
)
}
@@ -25,8 +25,15 @@ import { useNotifications } from '@/hooks/use-notifications'
import { useSystemConfig } from '@/hooks/use-system-config'
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
import { Dialog } from '@/components/dialog'
import { LanguageSwitcher } from '@/components/language-switcher'
import { NotificationPopover } from '@/components/notification-popover'
import { ProfileDropdown } from '@/components/profile-dropdown'
@@ -420,26 +427,28 @@ export function PublicHeader(props: PublicHeaderProps) {
closeAuthPrompt()
}
}}
title={t('Sign in required')}
description={t('Please sign in to view {{module}}.', {
module: authPromptTarget?.title || '',
})}
contentClassName='sm:max-w-md'
contentHeight='auto'
footer={
<>
>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Sign in required')}</DialogTitle>
<DialogDescription>
{t('Please sign in to view {{module}}.', {
module: authPromptTarget?.title || '',
})}
</DialogDescription>
</DialogHeader>
<div className='bg-muted/40 text-muted-foreground rounded-lg px-3 py-2 text-sm'>
{t('Redirecting to sign in in {{seconds}} seconds.', {
seconds: authPromptSecondsLeft,
})}
</div>
<DialogFooter>
<Button variant='outline' onClick={closeAuthPrompt}>
{t('Cancel')}
</Button>
<Button onClick={navigateToSignIn}>{t('Sign in now')}</Button>
</>
}
>
<div className='bg-muted/40 text-muted-foreground rounded-lg px-3 py-2 text-sm'>
{t('Redirecting to sign in in {{seconds}} seconds.', {
seconds: authPromptSecondsLeft,
})}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
@@ -20,9 +20,16 @@ import { useMemo } from 'react'
import { ShieldCheck, KeyRound, Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Dialog } from '@/components/dialog'
import type {
SecureVerificationState,
VerificationMethod,
@@ -84,118 +91,122 @@ export function SecureVerificationDialog({
(activeMethod === '2fa' && (!state.code.trim() || state.code.length < 6))
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
<ShieldCheck className='text-primary h-5 w-5' />
{title}
</>
}
description={description}
contentClassName='top-[8vh] max-w-[calc(100%-1.5rem)] translate-y-0 overflow-hidden border-none shadow-xl sm:top-1/2 sm:max-w-md sm:translate-y-[-50%] sm:rounded-xl'
headerClassName='border-b pb-4 text-left'
titleClassName='flex items-center gap-2 text-lg font-semibold'
descriptionClassName='text-left'
contentHeight='auto'
bodyClassName='px-1 py-1'
showCloseButton={!state.loading}
footerClassName='bg-muted/30 border-t px-6 py-4 sm:flex-row sm:justify-end'
footer={
<>
<Button
type='button'
variant='outline'
disabled={state.loading}
onClick={onCancel}
>
{t('Cancel')}
</Button>
<Button
type='button'
onClick={handleVerify}
disabled={availableTabs.length === 0 || verifyDisabled}
>
{state.loading && <Loader2 className='h-4 w-4 animate-spin' />}
{t('Verify')}
</Button>
</>
}
>
{availableTabs.length === 0 ? (
<div className='grid place-items-center gap-4 text-center'>
<div className='bg-muted flex h-16 w-16 items-center justify-center rounded-2xl'>
<ShieldCheck className='text-muted-foreground h-8 w-8' />
</div>
<p className='text-muted-foreground text-sm'>
{t(
'Enable Two-factor Authentication or Passkey in your profile to unlock sensitive operations.'
)}
</p>
</div>
) : (
<Tabs
value={activeMethod ?? availableTabs[0]}
onValueChange={(value) => onMethodChange(value as VerificationMethod)}
className='gap-4'
>
<TabsList>
{methods.has2FA && (
<TabsTrigger value='2fa'>{t('Authenticator code')}</TabsTrigger>
)}
{methods.hasPasskey && methods.passkeySupported && (
<TabsTrigger value='passkey'>{t('Passkey')}</TabsTrigger>
)}
</TabsList>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className='top-[8vh] max-w-[calc(100%-1.5rem)] translate-y-0 gap-0 overflow-hidden border-none p-0 shadow-xl sm:top-1/2 sm:max-w-md sm:translate-y-[-50%] sm:rounded-xl'
showCloseButton={!state.loading}
>
<div className='bg-background flex max-h-[calc(100dvh-2rem)] flex-col'>
<DialogHeader className='border-b px-6 py-5 text-left'>
<DialogTitle className='flex items-center gap-2 text-lg font-semibold'>
<ShieldCheck className='text-primary h-5 w-5' />
{title}
</DialogTitle>
<DialogDescription className='text-left'>
{description}
</DialogDescription>
</DialogHeader>
<TabsContent value='2fa' className='space-y-3'>
<p className='text-muted-foreground text-sm'>
{t(
'Enter the 6-digit Time-based One-Time Password or 8-character backup code from your authenticator app.'
)}
</p>
<Input
inputMode='numeric'
maxLength={8}
value={state.code}
onChange={(event) => onCodeChange(event.target.value)}
placeholder={t('Enter verification code')}
disabled={state.loading}
autoFocus={activeMethod === '2fa'}
onKeyDown={(event) => {
if (event.key === 'Enter' && !verifyDisabled) {
event.preventDefault()
handleVerify()
<div className='flex-1 overflow-y-auto px-6 py-5'>
{availableTabs.length === 0 ? (
<div className='grid place-items-center gap-4 text-center'>
<div className='bg-muted flex h-16 w-16 items-center justify-center rounded-2xl'>
<ShieldCheck className='text-muted-foreground h-8 w-8' />
</div>
<p className='text-muted-foreground text-sm'>
{t(
'Enable Two-factor Authentication or Passkey in your profile to unlock sensitive operations.'
)}
</p>
</div>
) : (
<Tabs
value={activeMethod ?? availableTabs[0]}
onValueChange={(value) =>
onMethodChange(value as VerificationMethod)
}
}}
/>
</TabsContent>
className='gap-4'
>
<TabsList>
{methods.has2FA && (
<TabsTrigger value='2fa'>
{t('Authenticator code')}
</TabsTrigger>
)}
{methods.hasPasskey && methods.passkeySupported && (
<TabsTrigger value='passkey'>{t('Passkey')}</TabsTrigger>
)}
</TabsList>
<TabsContent value='passkey' className='space-y-4'>
<div className='bg-muted/50 flex items-center justify-center rounded-lg p-4'>
<div className='text-muted-foreground flex items-center gap-3'>
<KeyRound className='text-primary h-6 w-6' />
<div className='text-left text-sm'>
<p className='text-foreground font-medium'>
{t('Use your Passkey')}
</p>
<p>
<TabsContent value='2fa' className='space-y-3'>
<p className='text-muted-foreground text-sm'>
{t(
'We will prompt your device to confirm using biometrics or your hardware key.'
'Enter the 6-digit Time-based One-Time Password or 8-character backup code from your authenticator app.'
)}
</p>
</div>
</div>
</div>
{!methods.passkeySupported && (
<p className='text-destructive text-sm'>
{t('This device does not support Passkey verification.')}
</p>
<Input
inputMode='numeric'
maxLength={8}
value={state.code}
onChange={(event) => onCodeChange(event.target.value)}
placeholder={t('Enter verification code')}
disabled={state.loading}
autoFocus={activeMethod === '2fa'}
onKeyDown={(event) => {
if (event.key === 'Enter' && !verifyDisabled) {
event.preventDefault()
handleVerify()
}
}}
/>
</TabsContent>
<TabsContent value='passkey' className='space-y-4'>
<div className='bg-muted/50 flex items-center justify-center rounded-lg p-4'>
<div className='text-muted-foreground flex items-center gap-3'>
<KeyRound className='text-primary h-6 w-6' />
<div className='text-left text-sm'>
<p className='text-foreground font-medium'>
{t('Use your Passkey')}
</p>
<p>
{t(
'We will prompt your device to confirm using biometrics or your hardware key.'
)}
</p>
</div>
</div>
</div>
{!methods.passkeySupported && (
<p className='text-destructive text-sm'>
{t('This device does not support Passkey verification.')}
</p>
)}
</TabsContent>
</Tabs>
)}
</TabsContent>
</Tabs>
)}
</div>
<DialogFooter className='bg-muted/30 border-t px-6 py-4 sm:flex-row sm:justify-end'>
<Button
type='button'
variant='outline'
disabled={state.loading}
onClick={onCancel}
>
{t('Cancel')}
</Button>
<Button
type='button'
onClick={handleVerify}
disabled={availableTabs.length === 0 || verifyDisabled}
>
{state.loading && <Loader2 className='h-4 w-4 animate-spin' />}
{t('Verify')}
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
)
}
@@ -32,6 +32,14 @@ import {
import { cn } from '@/lib/utils'
import { useStatus } from '@/hooks/use-status'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -42,7 +50,6 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { PasswordInput } from '@/components/password-input'
import { Turnstile } from '@/components/turnstile'
import { login, wechatLoginByCode } from '@/features/auth/api'
@@ -407,16 +414,43 @@ export function UserAuthForm({
<Dialog
open={isWeChatDialogOpen}
onOpenChange={handleWeChatDialogChange}
title={t('WeChat sign in')}
description={t(
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
)}
contentClassName='max-w-sm'
headerClassName='text-left'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
>
<DialogContent className='max-w-sm'>
<DialogHeader className='text-left'>
<DialogTitle>{t('WeChat sign in')}</DialogTitle>
<DialogDescription>
{t(
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
)}
</DialogDescription>
</DialogHeader>
{wechatQrCodeUrl ? (
<div className='flex justify-center'>
<img
src={wechatQrCodeUrl}
alt={t('WeChat login QR code')}
className='h-40 w-40 rounded-md border object-contain'
/>
</div>
) : (
<p className='text-muted-foreground text-sm'>
{t('QR code is not configured. Please contact support.')}
</p>
)}
<div className='grid gap-2'>
<Label htmlFor='wechat-code'>{t('Verification code')}</Label>
<Input
id='wechat-code'
placeholder={t('Enter the verification code')}
value={wechatCode}
onChange={(event) => setWeChatCode(event.target.value)}
autoComplete='one-time-code'
/>
</div>
<DialogFooter>
<Button
type='button'
variant='outline'
@@ -440,32 +474,8 @@ export function UserAuthForm({
) : null}
{t('Confirm')}
</Button>
</>
}
>
{wechatQrCodeUrl ? (
<div className='flex justify-center'>
<img
src={wechatQrCodeUrl}
alt={t('WeChat login QR code')}
className='h-40 w-40 rounded-md border object-contain'
/>
</div>
) : (
<p className='text-muted-foreground text-sm'>
{t('QR code is not configured. Please contact support.')}
</p>
)}
<div className='grid gap-2'>
<Label htmlFor='wechat-code'>{t('Verification code')}</Label>
<Input
id='wechat-code'
placeholder={t('Enter the verification code')}
value={wechatCode}
onChange={(event) => setWeChatCode(event.target.value)}
autoComplete='one-time-code'
/>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</Form>
@@ -26,6 +26,14 @@ import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { useStatus } from '@/hooks/use-status'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -36,7 +44,6 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { PasswordInput } from '@/components/password-input'
import { Turnstile } from '@/components/turnstile'
import { register, wechatLoginByCode } from '@/features/auth/api'
@@ -380,16 +387,43 @@ export function SignUpForm({
<Dialog
open={isWeChatDialogOpen}
onOpenChange={handleWeChatDialogChange}
title={t('WeChat sign in')}
description={t(
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
)}
contentClassName='max-w-sm'
headerClassName='text-left'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
>
<DialogContent className='max-w-sm'>
<DialogHeader className='text-left'>
<DialogTitle>{t('WeChat sign in')}</DialogTitle>
<DialogDescription>
{t(
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
)}
</DialogDescription>
</DialogHeader>
{wechatQrCodeUrl ? (
<div className='flex justify-center'>
<img
src={wechatQrCodeUrl}
alt={t('WeChat login QR code')}
className='h-40 w-40 rounded-md border object-contain'
/>
</div>
) : (
<p className='text-muted-foreground text-sm'>
{t('QR code is not configured. Please contact support.')}
</p>
)}
<div className='grid gap-2'>
<Label htmlFor='wechat-code'>{t('Verification code')}</Label>
<Input
id='wechat-code'
placeholder={t('Enter the verification code')}
value={wechatCode}
onChange={(event) => setWeChatCode(event.target.value)}
autoComplete='one-time-code'
/>
</div>
<DialogFooter>
<Button
type='button'
variant='outline'
@@ -413,32 +447,8 @@ export function SignUpForm({
) : null}
{t('Confirm')}
</Button>
</>
}
>
{wechatQrCodeUrl ? (
<div className='flex justify-center'>
<img
src={wechatQrCodeUrl}
alt={t('WeChat login QR code')}
className='h-40 w-40 rounded-md border object-contain'
/>
</div>
) : (
<p className='text-muted-foreground text-sm'>
{t('QR code is not configured. Please contact support.')}
</p>
)}
<div className='grid gap-2'>
<Label htmlFor='wechat-code'>{t('Verification code')}</Label>
<Input
id='wechat-code'
placeholder={t('Enter the verification code')}
value={wechatCode}
onChange={(event) => setWeChatCode(event.target.value)}
autoComplete='one-time-code'
/>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</Form>
@@ -22,6 +22,14 @@ import { type Table } from '@tanstack/react-table'
import { Power, PowerOff, Tag, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
@@ -30,7 +38,6 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import {
handleBatchDelete,
handleBatchDisable,
@@ -181,21 +188,29 @@ export function DataTableBulkActions<TData>({
</BulkActionsToolbar>
{/* Set Tag Dialog */}
<Dialog
open={showTagDialog}
onOpenChange={setShowTagDialog}
title={t('Set Tag')}
description={
<>
{t('Set a tag for')}
{selectedIds.length}{' '}
{t('selected channel(s). Leave empty to remove tag.')}
</>
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Dialog open={showTagDialog} onOpenChange={setShowTagDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Set Tag')}</DialogTitle>
<DialogDescription>
{t('Set a tag for')} {selectedIds.length}{' '}
{t('selected channel(s). Leave empty to remove tag.')}
</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='tag'>{t('Tag')}</Label>
<Input
id='tag'
placeholder={t('Enter tag name (optional)')}
value={tagValue}
onChange={(e) => setTagValue(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={() => {
@@ -206,37 +221,22 @@ export function DataTableBulkActions<TData>({
{t('Cancel')}
</Button>
<Button onClick={handleSetTag}>{t('Set Tag')}</Button>
</>
}
>
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='tag'>{t('Tag')}</Label>
<Input
id='tag'
placeholder={t('Enter tag name (optional)')}
value={tagValue}
onChange={(e) => setTagValue(e.target.value)}
/>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog
open={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
title={t('Delete Channels?')}
description={
<>
{t('Are you sure you want to delete')}
{selectedIds.length}{' '}
{t('channel(s)? This action cannot be undone.')}
</>
}
contentHeight='auto'
footer={
<>
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Delete Channels?')}</DialogTitle>
<DialogDescription>
{t('Are you sure you want to delete')} {selectedIds.length}{' '}
{t('channel(s)? This action cannot be undone.')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant='outline'
onClick={() => setShowDeleteConfirm(false)}
@@ -246,10 +246,8 @@ export function DataTableBulkActions<TData>({
<Button variant='destructive' onClick={handleDeleteAll}>
{t('Delete')}
</Button>
</>
}
>
{' '}
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
@@ -24,7 +24,14 @@ import { toast } from 'sonner'
import { formatCurrencyFromUSD } from '@/lib/currency'
import { formatTimestampToDate } from '@/lib/format'
import { Button } from '@/components/ui/button'
import { Dialog } from '@/components/dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { getCodexUsage, updateChannelBalance } from '../../api'
import { channelsQueryKeys } from '../../lib'
import { useChannels } from '../channels-provider'
@@ -154,55 +161,53 @@ export function BalanceQueryDialog({
}
return (
<Dialog
open={open}
onOpenChange={handleClose}
title={t('Query Balance')}
description={
<>
{t('Update balance for:')}
<strong>{currentRow.name}</strong>
</>
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Query Balance')}</DialogTitle>
<DialogDescription>
{t('Update balance for:')} <strong>{currentRow.name}</strong>
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
{/* Current Balance Display */}
<div className='bg-muted/50 rounded-lg border p-4'>
<div className='text-muted-foreground mb-2 flex items-center gap-2 text-sm'>
<DollarSign className='h-4 w-4' />
<span>{t('Current Balance')}</span>
</div>
<div className='text-2xl font-bold'>
{balance !== null
? formatBalance(balance)
: formatBalance(currentRow.balance)}
</div>
<div className='text-muted-foreground mt-2 text-xs'>
{t('Last updated:')}{' '}
{formatDate(
balanceUpdatedTime ?? currentRow.balance_updated_time
)}
</div>
</div>
{/* Balance Update Button */}
<Button
className='w-full'
onClick={handleQueryBalance}
disabled={isQuerying}
>
{isQuerying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{!isQuerying && <RefreshCw className='mr-2 h-4 w-4' />}
{isQuerying ? t('Querying...') : t('Update Balance')}
</Button>
</div>
<DialogFooter>
<Button variant='outline' onClick={handleClose} disabled={isQuerying}>
{t('Close')}
</Button>
</>
}
>
<div className='space-y-4 py-4'>
{/* Current Balance Display */}
<div className='bg-muted/50 rounded-lg border p-4'>
<div className='text-muted-foreground mb-2 flex items-center gap-2 text-sm'>
<DollarSign className='h-4 w-4' />
<span>{t('Current Balance')}</span>
</div>
<div className='text-2xl font-bold'>
{balance !== null
? formatBalance(balance)
: formatBalance(currentRow.balance)}
</div>
<div className='text-muted-foreground mt-2 text-xs'>
{t('Last updated:')}{' '}
{formatDate(balanceUpdatedTime ?? currentRow.balance_updated_time)}
</div>
</div>
{/* Balance Update Button */}
<Button
className='w-full'
onClick={handleQueryBalance}
disabled={isQuerying}
>
{isQuerying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{!isQuerying && <RefreshCw className='mr-2 h-4 w-4' />}
{isQuerying ? t('Querying...') : t('Update Balance')}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -33,6 +33,14 @@ import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { useIsMobile } from '@/hooks/use-mobile'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
@@ -67,7 +75,6 @@ import {
} from '@/components/ui/tooltip'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { DataTablePagination } from '@/components/data-table/pagination'
import { Dialog } from '@/components/dialog'
import {
sideDrawerContentClassName,
sideDrawerFooterClassName,
@@ -522,184 +529,179 @@ export function ChannelTestDialog({
return (
<>
<Dialog
open={open}
onOpenChange={handleClose}
title={t('Test Channel Connection')}
description={
<>
{t('Test connectivity for:')}
<strong>{currentRow.name}</strong>
</>
}
contentClassName='max-h-[90vh] overflow-hidden sm:max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className='max-h-[90vh] overflow-hidden sm:max-w-3xl'>
<DialogHeader>
<DialogTitle>{t('Test Channel Connection')}</DialogTitle>
<DialogDescription>
{t('Test connectivity for:')} <strong>{currentRow.name}</strong>
</DialogDescription>
</DialogHeader>
<div className='max-h-[78vh] space-y-4 overflow-y-auto py-4 pr-1'>
<div className='grid gap-4 md:grid-cols-2'>
<div className='grid gap-2'>
<Label htmlFor='endpoint-type'>{t('Endpoint Type')}</Label>
<Select
items={[
...endpointTypeOptions.map((option) => {
const itemValue = option.value
return { value: itemValue, label: t(option.label) }
}),
]}
value={endpointType}
onValueChange={(v) => v !== null && setEndpointType(v)}
>
<SelectTrigger id='endpoint-type'>
<SelectValue placeholder={t('Auto detect (default)')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{endpointTypeOptions.map((option) => {
const itemValue = option.value
return (
<SelectItem key={itemValue} value={itemValue}>
{t(option.label)}
</SelectItem>
)
})}
</SelectGroup>
</SelectContent>
</Select>
<p className='text-muted-foreground text-xs'>
{t(
'Override the endpoint used for testing. Leave empty to auto detect.'
)}
</p>
</div>
<div className='grid gap-2'>
<Label htmlFor='stream-toggle'>{t('Stream Mode')}</Label>
<div className='flex items-center gap-2'>
<Switch
id='stream-toggle'
checked={isStreamTest}
onCheckedChange={setIsStreamTest}
disabled={streamDisabled}
/>
<span className='text-sm'>
{isStreamTest ? t('Enabled') : t('Disabled')}
</span>
</div>
<p className='text-muted-foreground text-xs'>
{t('Enable streaming mode for the test request.')}
</p>
</div>
</div>
<div className='space-y-3 max-sm:has-[div[role="toolbar"]]:pb-16'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='text-sm font-medium'>{t('Channel models')}</p>
<p className='text-muted-foreground text-xs'>
{t('Select models to run batch tests.')}
</p>
</div>
<Input
placeholder={t('Filter models...')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='sm:w-64'
/>
</div>
<div className='space-y-3'>
<div
className='overflow-hidden rounded-md border'
role='region'
aria-label={t('Channel models')}
>
<div className='max-h-90 overflow-auto **:data-[slot=table-container]:overflow-visible'>
<Table className='w-max min-w-full table-auto'>
<colgroup>
<col className='w-10 min-w-10' />
<col className='w-auto' />
<col className='w-70' />
<col className='w-24 sm:w-28' />
</colgroup>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className={getTestTableColumnClass(
header.column.id
)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() ? 'selected' : undefined
}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={getTestTableColumnClass(
cell.column.id
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={table.getVisibleLeafColumns().length}
className='text-muted-foreground h-16 text-center text-sm'
>
{models.length
? 'No models matched your search.'
: 'This channel has no configured models.'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<DataTablePagination table={table} />
</div>
<TestModelsBulkActions
table={table}
disabled={isAnyTesting}
onTestSelected={handleBatchTest}
/>
</div>
</div>
<DialogFooter>
<Button variant='outline' onClick={handleClose}>
{t('Close')}
</Button>
</>
}
>
<div className='max-h-[78vh] space-y-4 overflow-y-auto py-4 pr-1'>
<div className='grid gap-4 md:grid-cols-2'>
<div className='grid gap-2'>
<Label htmlFor='endpoint-type'>{t('Endpoint Type')}</Label>
<Select
items={[
...endpointTypeOptions.map((option) => {
const itemValue = option.value
return { value: itemValue, label: t(option.label) }
}),
]}
value={endpointType}
onValueChange={(v) => v !== null && setEndpointType(v)}
>
<SelectTrigger id='endpoint-type'>
<SelectValue placeholder={t('Auto detect (default)')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{endpointTypeOptions.map((option) => {
const itemValue = option.value
return (
<SelectItem key={itemValue} value={itemValue}>
{t(option.label)}
</SelectItem>
)
})}
</SelectGroup>
</SelectContent>
</Select>
<p className='text-muted-foreground text-xs'>
{t(
'Override the endpoint used for testing. Leave empty to auto detect.'
)}
</p>
</div>
<div className='grid gap-2'>
<Label htmlFor='stream-toggle'>{t('Stream Mode')}</Label>
<div className='flex items-center gap-2'>
<Switch
id='stream-toggle'
checked={isStreamTest}
onCheckedChange={setIsStreamTest}
disabled={streamDisabled}
/>
<span className='text-sm'>
{isStreamTest ? t('Enabled') : t('Disabled')}
</span>
</div>
<p className='text-muted-foreground text-xs'>
{t('Enable streaming mode for the test request.')}
</p>
</div>
</div>
<div className='space-y-3 max-sm:has-[div[role="toolbar"]]:pb-16'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='text-sm font-medium'>{t('Channel models')}</p>
<p className='text-muted-foreground text-xs'>
{t('Select models to run batch tests.')}
</p>
</div>
<Input
placeholder={t('Filter models...')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='sm:w-64'
/>
</div>
<div className='space-y-3'>
<div
className='overflow-hidden rounded-md border'
role='region'
aria-label={t('Channel models')}
>
<div className='max-h-90 overflow-auto **:data-[slot=table-container]:overflow-visible'>
<Table className='w-max min-w-full table-auto'>
<colgroup>
<col className='w-10 min-w-10' />
<col className='w-auto' />
<col className='w-70' />
<col className='w-24 sm:w-28' />
</colgroup>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className={getTestTableColumnClass(
header.column.id
)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() ? 'selected' : undefined
}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={getTestTableColumnClass(
cell.column.id
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={table.getVisibleLeafColumns().length}
className='text-muted-foreground h-16 text-center text-sm'
>
{models.length
? 'No models matched your search.'
: 'This channel has no configured models.'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<DataTablePagination table={table} />
</div>
<TestModelsBulkActions
table={table}
disabled={isAnyTesting}
onTestSelected={handleBatchTest}
/>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<FailureDetailsSheet
details={failureDetails}
@@ -24,8 +24,15 @@ import { tryPrettyJson } from '@/lib/utils'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog'
import { completeCodexOAuth, startCodexOAuth } from '../../api'
type CodexOAuthDialogProps = {
@@ -122,18 +129,78 @@ export function CodexOAuthDialog({
}
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Codex Authorization')}
description={t(
'Generate a Codex OAuth credential and paste it into the channel key field.'
)}
contentClassName='sm:max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-2xl'>
<DialogHeader>
<DialogTitle>{t('Codex Authorization')}</DialogTitle>
<DialogDescription>
{t(
'Generate a Codex OAuth credential and paste it into the channel key field.'
)}
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
<Alert>
<AlertDescription>
{t(
'1) Click "Open authorization page" and complete login. 2) Your browser may redirect to localhost (it is OK if the page does not load). 3) Copy the full URL from the address bar and paste it below. 4) Click "Generate credential".'
)}
</AlertDescription>
</Alert>
<div className='flex flex-wrap gap-2'>
<Button onClick={handleStart} disabled={state.isStarting}>
{state.isStarting ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<ExternalLink className='mr-2 h-4 w-4' />
)}
{t('Open authorization page')}
</Button>
<Button
type='button'
variant='outline'
disabled={!canCopyAuthorizeUrl}
onClick={async () => {
if (!state.authorizeUrl) return
await copyToClipboard(state.authorizeUrl)
}}
aria-label={t('Copy authorization link')}
title={t('Copy authorization link')}
>
{copiedText === state.authorizeUrl ? (
<Check className='mr-2 h-4 w-4 text-green-600' />
) : (
<Copy className='mr-2 h-4 w-4' />
)}
{t('Copy authorization link')}
</Button>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>{t('Callback URL')}</div>
<Input
value={state.callbackUrl}
onChange={(e) =>
setState((prev) => ({ ...prev, callbackUrl: e.target.value }))
}
placeholder={t(
'Paste the full callback URL (includes code & state)'
)}
autoComplete='off'
spellCheck={false}
/>
<div className='text-muted-foreground text-xs'>
{t(
'Tip: The generated key is a JSON credential including access_token / refresh_token / account_id.'
)}
</div>
</div>
</div>
<DialogFooter>
<Button
type='button'
variant='outline'
@@ -148,68 +215,8 @@ export function CodexOAuthDialog({
)}
{state.isCompleting ? t('Generating...') : t('Generate credential')}
</Button>
</>
}
>
<div className='space-y-4'>
<Alert>
<AlertDescription>
{t(
'1) Click "Open authorization page" and complete login. 2) Your browser may redirect to localhost (it is OK if the page does not load). 3) Copy the full URL from the address bar and paste it below. 4) Click "Generate credential".'
)}
</AlertDescription>
</Alert>
<div className='flex flex-wrap gap-2'>
<Button onClick={handleStart} disabled={state.isStarting}>
{state.isStarting ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<ExternalLink className='mr-2 h-4 w-4' />
)}
{t('Open authorization page')}
</Button>
<Button
type='button'
variant='outline'
disabled={!canCopyAuthorizeUrl}
onClick={async () => {
if (!state.authorizeUrl) return
await copyToClipboard(state.authorizeUrl)
}}
aria-label={t('Copy authorization link')}
title={t('Copy authorization link')}
>
{copiedText === state.authorizeUrl ? (
<Check className='mr-2 h-4 w-4 text-green-600' />
) : (
<Copy className='mr-2 h-4 w-4' />
)}
{t('Copy authorization link')}
</Button>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>{t('Callback URL')}</div>
<Input
value={state.callbackUrl}
onChange={(e) =>
setState((prev) => ({ ...prev, callbackUrl: e.target.value }))
}
placeholder={t(
'Paste the full callback URL (includes code & state)'
)}
autoComplete='off'
spellCheck={false}
/>
<div className='text-muted-foreground text-xs'>
{t(
'Tip: The generated key is a JSON credential including access_token / refresh_token / account_id.'
)}
</div>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -31,9 +31,16 @@ import { useTranslation } from 'react-i18next'
import dayjs from '@/lib/dayjs'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Progress } from '@/components/ui/progress'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Dialog } from '@/components/dialog'
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
type CodexRateLimitWindow = {
@@ -407,23 +414,177 @@ export function CodexUsageDialog({
}, [response])
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Codex Account & Usage')}
description={
<>
{t('Channel:')}
<strong>{channelName || '-'}</strong>{' '}
{channelId ? `(#${channelId})` : ''}
</>
}
contentClassName='sm:max-w-3xl'
titleClassName='flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-3xl'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
{t('Codex Account & Usage')}
</DialogTitle>
<DialogDescription>
{t('Channel:')} <strong>{channelName || '-'}</strong>{' '}
{channelId ? `(#${channelId})` : ''}
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
{errorMessage && (
<div className='rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400'>
{errorMessage}
</div>
)}
{/* Account summary */}
<div className='rounded-lg border p-4'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='flex flex-wrap items-center gap-2'>
<StatusBadge
label={accountBadge.label}
variant={accountBadge.variant}
copyable={false}
/>
{statusBadge}
{typeof response?.upstream_status === 'number' && (
<StatusBadge
label={`${t('Status:')} ${response.upstream_status}`}
variant='neutral'
copyable={false}
/>
)}
</div>
{onRefresh && (
<Button
type='button'
variant='outline'
size='sm'
onClick={onRefresh}
disabled={Boolean(isRefreshing)}
>
<RefreshCw className='mr-1.5 h-3.5 w-3.5' />
{t('Refresh')}
</Button>
)}
</div>
{/* Account identity info */}
<div className='bg-muted/30 mt-3 rounded-md px-3 py-2'>
<CopyableField
icon={<User className='h-3.5 w-3.5' />}
label='User ID'
value={payload?.user_id}
mono
/>
<CopyableField
icon={<Mail className='h-3.5 w-3.5' />}
label={t('Email')}
value={payload?.email}
/>
<CopyableField
icon={<Hash className='h-3.5 w-3.5' />}
label='Account ID'
value={payload?.account_id}
mono
/>
</div>
</div>
{/* Rate limit windows */}
<div className='space-y-5'>
<div>
<div className='mb-1 text-sm font-medium'>
{t('Rate Limit Windows')}
</div>
<p className='text-muted-foreground mb-3 text-xs'>
{t(
'Tracks current account base limits and additional metered usage on Codex upstream.'
)}
</p>
<RateLimitGroupSection
title={t('Base Limits')}
description={t('Base rate limit windows for this account.')}
source={payload}
/>
</div>
{additionalRateLimits.length > 0 && (
<div className='space-y-4 border-t pt-4'>
<div>
<div className='text-sm font-medium'>
{t('Additional Limits')}
</div>
<p className='text-muted-foreground text-xs'>
{t(
'Per-feature metered windows split by model or capability.'
)}
</p>
</div>
<div className='space-y-4'>
{additionalRateLimits.map((item, index) => {
const limitName =
item.limit_name ||
item.metered_feature ||
`${t('Additional Limit')} ${index + 1}`
return (
<div
key={`${limitName}-${item.metered_feature ?? ''}-${index}`}
className={index > 0 ? 'border-t pt-4' : ''}
>
<RateLimitGroupSection
title={limitName}
description={t('Additional metered capability')}
source={item}
meteredFeature={item.metered_feature}
/>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Raw JSON collapsible */}
<div className='rounded-lg border'>
<button
type='button'
className='hover:bg-muted/40 flex w-full items-center justify-between gap-2 p-3 transition-colors'
onClick={() => setShowRawJson((v) => !v)}
>
<div className='text-sm font-medium'>{t('Raw JSON')}</div>
{showRawJson ? (
<ChevronUp className='text-muted-foreground h-4 w-4' />
) : (
<ChevronDown className='text-muted-foreground h-4 w-4' />
)}
</button>
{showRawJson && (
<>
<div className='flex justify-end border-t px-3 py-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => copyToClipboard(rawJsonText)}
disabled={!rawJsonText}
>
{copiedText === rawJsonText ? (
<Check className='mr-1.5 h-3.5 w-3.5 text-green-600' />
) : (
<Copy className='mr-1.5 h-3.5 w-3.5' />
)}
{t('Copy')}
</Button>
</div>
<ScrollArea className='max-h-[50vh]'>
<pre className='bg-muted/30 m-0 p-3 text-xs break-words whitespace-pre-wrap'>
{rawJsonText || '-'}
</pre>
</ScrollArea>
</>
)}
</div>
</div>
<DialogFooter>
<Button
type='button'
variant='outline'
@@ -431,166 +592,8 @@ export function CodexUsageDialog({
>
{t('Close')}
</Button>
</>
}
>
<div className='space-y-4'>
{errorMessage && (
<div className='rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400'>
{errorMessage}
</div>
)}
{/* Account summary */}
<div className='rounded-lg border p-4'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='flex flex-wrap items-center gap-2'>
<StatusBadge
label={accountBadge.label}
variant={accountBadge.variant}
copyable={false}
/>
{statusBadge}
{typeof response?.upstream_status === 'number' && (
<StatusBadge
label={`${t('Status:')} ${response.upstream_status}`}
variant='neutral'
copyable={false}
/>
)}
</div>
{onRefresh && (
<Button
type='button'
variant='outline'
size='sm'
onClick={onRefresh}
disabled={Boolean(isRefreshing)}
>
<RefreshCw className='mr-1.5 h-3.5 w-3.5' />
{t('Refresh')}
</Button>
)}
</div>
{/* Account identity info */}
<div className='bg-muted/30 mt-3 rounded-md px-3 py-2'>
<CopyableField
icon={<User className='h-3.5 w-3.5' />}
label='User ID'
value={payload?.user_id}
mono
/>
<CopyableField
icon={<Mail className='h-3.5 w-3.5' />}
label={t('Email')}
value={payload?.email}
/>
<CopyableField
icon={<Hash className='h-3.5 w-3.5' />}
label='Account ID'
value={payload?.account_id}
mono
/>
</div>
</div>
{/* Rate limit windows */}
<div className='space-y-5'>
<div>
<div className='mb-1 text-sm font-medium'>
{t('Rate Limit Windows')}
</div>
<p className='text-muted-foreground mb-3 text-xs'>
{t(
'Tracks current account base limits and additional metered usage on Codex upstream.'
)}
</p>
<RateLimitGroupSection
title={t('Base Limits')}
description={t('Base rate limit windows for this account.')}
source={payload}
/>
</div>
{additionalRateLimits.length > 0 && (
<div className='space-y-4 border-t pt-4'>
<div>
<div className='text-sm font-medium'>
{t('Additional Limits')}
</div>
<p className='text-muted-foreground text-xs'>
{t(
'Per-feature metered windows split by model or capability.'
)}
</p>
</div>
<div className='space-y-4'>
{additionalRateLimits.map((item, index) => {
const limitName =
item.limit_name ||
item.metered_feature ||
`${t('Additional Limit')} ${index + 1}`
return (
<div
key={`${limitName}-${item.metered_feature ?? ''}-${index}`}
className={index > 0 ? 'border-t pt-4' : ''}
>
<RateLimitGroupSection
title={limitName}
description={t('Additional metered capability')}
source={item}
meteredFeature={item.metered_feature}
/>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Raw JSON collapsible */}
<div className='rounded-lg border'>
<button
type='button'
className='hover:bg-muted/40 flex w-full items-center justify-between gap-2 p-3 transition-colors'
onClick={() => setShowRawJson((v) => !v)}
>
<div className='text-sm font-medium'>{t('Raw JSON')}</div>
{showRawJson ? (
<ChevronUp className='text-muted-foreground h-4 w-4' />
) : (
<ChevronDown className='text-muted-foreground h-4 w-4' />
)}
</button>
{showRawJson && (
<>
<div className='flex justify-end border-t px-3 py-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => copyToClipboard(rawJsonText)}
disabled={!rawJsonText}
>
{copiedText === rawJsonText ? (
<Check className='mr-1.5 h-3.5 w-3.5 text-green-600' />
) : (
<Copy className='mr-1.5 h-3.5 w-3.5' />
)}
{t('Copy')}
</Button>
</div>
<ScrollArea className='max-h-[50vh]'>
<pre className='bg-muted/30 m-0 p-3 text-xs break-words whitespace-pre-wrap'>
{rawJsonText || '-'}
</pre>
</ScrollArea>
</>
)}
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -22,9 +22,16 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { handleCopyChannel } from '../../lib'
import { useChannels } from '../channels-provider'
@@ -67,20 +74,45 @@ export function CopyChannelDialog({
}
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Copy Channel')}
description={
<>
{t('Create a copy of:')}
<strong>{currentRow.name}</strong>
</>
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Copy Channel')}</DialogTitle>
<DialogDescription>
{t('Create a copy of:')} <strong>{currentRow.name}</strong>
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label htmlFor='suffix'>{t('Name Suffix')}</Label>
<Input
id='suffix'
placeholder={t('_copy')}
value={suffix}
onChange={(e) => setSuffix(e.target.value)}
disabled={isCopying}
/>
<p className='text-muted-foreground text-xs'>
{t('New name will be:')} {currentRow.name}
{suffix}
</p>
</div>
<div className='flex items-center space-x-2'>
<Checkbox
id='reset-balance'
checked={resetBalance}
onCheckedChange={(checked) => setResetBalance(!!checked)}
disabled={isCopying}
/>
<Label htmlFor='reset-balance' className='text-sm font-normal'>
{t('Reset balance and used quota')}
</Label>
</div>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
@@ -90,39 +122,10 @@ export function CopyChannelDialog({
</Button>
<Button onClick={handleCopy} disabled={isCopying}>
{isCopying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isCopying ? t('Copying...') : t('Copy Channel')}
{isCopying ? 'Copying...' : 'Copy Channel'}
</Button>
</>
}
>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label htmlFor='suffix'>{t('Name Suffix')}</Label>
<Input
id='suffix'
placeholder={t('_copy')}
value={suffix}
onChange={(e) => setSuffix(e.target.value)}
disabled={isCopying}
/>
<p className='text-muted-foreground text-xs'>
{t('New name will be:')} {currentRow.name}
{suffix}
</p>
</div>
<div className='flex items-center space-x-2'>
<Checkbox
id='reset-balance'
checked={resetBalance}
onCheckedChange={(checked) => setResetBalance(!!checked)}
disabled={isCopying}
/>
<Label htmlFor='reset-balance' className='text-sm font-normal'>
{t('Reset balance and used quota')}
</Label>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -22,6 +22,14 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
@@ -35,7 +43,6 @@ import {
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { GroupBadge } from '@/components/group-badge'
import { StatusBadge } from '@/components/status-badge'
import {
@@ -215,23 +222,216 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
if (!currentTag) return null
return (
<Dialog
open={open}
onOpenChange={handleClose}
title={
<>
{t('Edit Tag:')}
{currentTag}
</>
}
description={t(
'Batch edit all channels with this tag. Leave fields empty to keep current values.'
)}
contentClassName='max-h-[90vh] max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className='max-h-[90vh] max-w-2xl'>
<DialogHeader>
<DialogTitle>
{t('Edit Tag:')} {currentTag}
</DialogTitle>
<DialogDescription>
{t(
'Batch edit all channels with this tag. Leave fields empty to keep current values.'
)}
</DialogDescription>
</DialogHeader>
<ScrollArea className='max-h-[60vh] pr-4'>
<div className='space-y-6'>
{/* Tag Name */}
<div className='space-y-2'>
<Label htmlFor='new-tag'>
{t('Tag Name')}
<span className='text-muted-foreground ml-2 text-xs'>
{t('(Leave empty to dissolve tag)')}
</span>
</Label>
<Input
id='new-tag'
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder={t('Enter new tag name or leave empty')}
/>
</div>
<Separator />
{/* Models */}
<div className='space-y-2'>
<Label>
{t('Models')}
<span className='text-muted-foreground ml-2 text-xs'>
{t("(Override all channels' models)")}
</span>
</Label>
{isLoadingTagModels ? (
<div className='flex items-center gap-2 py-4'>
<Loader2 className='h-4 w-4 animate-spin' />
<span className='text-muted-foreground text-sm'>
{t('Loading current models...')}
</span>
</div>
) : (
<>
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
{selectedModels.length > 0 ? (
selectedModels.map((model) => (
<StatusBadge
key={model}
variant='neutral'
className='cursor-pointer transition-opacity hover:opacity-70'
copyable={false}
onClick={() => handleRemoveModel(model)}
>
{model} ×
</StatusBadge>
))
) : (
<span className='text-muted-foreground text-sm'>
{t('No models selected')}
</span>
)}
</div>
<div className='flex gap-2'>
<Select<string>
items={[
...availableModels.map((model) => ({
value: model,
label: model,
})),
]}
onValueChange={(value) => {
if (value === null) return
if (!selectedModels.includes(value)) {
setSelectedModels([...selectedModels, value])
}
}}
>
<SelectTrigger className='flex-1'>
<SelectValue
placeholder={t('Add from available models...')}
/>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<ScrollArea className='h-60'>
{availableModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</ScrollArea>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='flex gap-2'>
<Input
placeholder={t('Custom model (comma-separated)')}
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddCustomModel()
}
}}
/>
<Button
type='button'
variant='secondary'
onClick={handleAddCustomModel}
>
{t('Add')}
</Button>
</div>
</>
)}
</div>
<Separator />
{/* Model Mapping */}
<div className='space-y-2'>
<Label htmlFor='model-mapping'>
{t('Model Mapping (JSON)')}
<span className='text-muted-foreground ml-2 text-xs'>
{t('(Optional: redirect model names)')}
</span>
</Label>
<Textarea
id='model-mapping'
value={modelMapping}
onChange={(e) => setModelMapping(e.target.value)}
placeholder={'{\n "gpt-3.5-turbo": "gpt-3.5-turbo-0125"\n}'}
rows={4}
className='font-mono text-sm'
/>
<div className='flex gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
setModelMapping(
JSON.stringify(
{ 'gpt-3.5-turbo': 'gpt-3.5-turbo-0125' },
null,
2
)
)
}
>
{t('Example')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setModelMapping(JSON.stringify({}, null, 2))}
>
{t('Clear Mapping')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setModelMapping('')}
>
{t('No Change')}
</Button>
</div>
</div>
<Separator />
{/* Groups */}
<div className='space-y-2'>
<Label>
{t('Groups')}
<span className='text-muted-foreground ml-2 text-xs'>
{t("(Override all channels' groups)")}
</span>
</Label>
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
{availableGroups.map((group) => (
<GroupBadge
key={group}
group={group}
className={`cursor-pointer rounded-sm transition-opacity hover:opacity-70 ${
selectedGroups.includes(group) ? 'bg-muted/70 px-1' : ''
}`}
onClick={() => handleToggleGroup(group)}
/>
))}
</div>
</div>
</div>
</ScrollArea>
<DialogFooter>
<Button variant='outline' onClick={handleClose}>
{t('Cancel')}
</Button>
@@ -239,204 +439,8 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
{isSubmitting && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{t('Save Changes')}
</Button>
</>
}
>
<ScrollArea className='max-h-[60vh] pr-4'>
<div className='space-y-6'>
{/* Tag Name */}
<div className='space-y-2'>
<Label htmlFor='new-tag'>
{t('Tag Name')}
<span className='text-muted-foreground ml-2 text-xs'>
{t('(Leave empty to dissolve tag)')}
</span>
</Label>
<Input
id='new-tag'
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder={t('Enter new tag name or leave empty')}
/>
</div>
<Separator />
{/* Models */}
<div className='space-y-2'>
<Label>
{t('Models')}
<span className='text-muted-foreground ml-2 text-xs'>
{t("(Override all channels' models)")}
</span>
</Label>
{isLoadingTagModels ? (
<div className='flex items-center gap-2 py-4'>
<Loader2 className='h-4 w-4 animate-spin' />
<span className='text-muted-foreground text-sm'>
{t('Loading current models...')}
</span>
</div>
) : (
<>
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
{selectedModels.length > 0 ? (
selectedModels.map((model) => (
<StatusBadge
key={model}
variant='neutral'
className='cursor-pointer transition-opacity hover:opacity-70'
copyable={false}
onClick={() => handleRemoveModel(model)}
>
{model} ×
</StatusBadge>
))
) : (
<span className='text-muted-foreground text-sm'>
{t('No models selected')}
</span>
)}
</div>
<div className='flex gap-2'>
<Select<string>
items={[
...availableModels.map((model) => ({
value: model,
label: model,
})),
]}
onValueChange={(value) => {
if (value === null) return
if (!selectedModels.includes(value)) {
setSelectedModels([...selectedModels, value])
}
}}
>
<SelectTrigger className='flex-1'>
<SelectValue
placeholder={t('Add from available models...')}
/>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<ScrollArea className='h-60'>
{availableModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</ScrollArea>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='flex gap-2'>
<Input
placeholder={t('Custom model (comma-separated)')}
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddCustomModel()
}
}}
/>
<Button
type='button'
variant='secondary'
onClick={handleAddCustomModel}
>
{t('Add')}
</Button>
</div>
</>
)}
</div>
<Separator />
{/* Model Mapping */}
<div className='space-y-2'>
<Label htmlFor='model-mapping'>
{t('Model Mapping (JSON)')}
<span className='text-muted-foreground ml-2 text-xs'>
{t('(Optional: redirect model names)')}
</span>
</Label>
<Textarea
id='model-mapping'
value={modelMapping}
onChange={(e) => setModelMapping(e.target.value)}
placeholder={'{\n "gpt-3.5-turbo": "gpt-3.5-turbo-0125"\n}'}
rows={4}
className='font-mono text-sm'
/>
<div className='flex gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
setModelMapping(
JSON.stringify(
{ 'gpt-3.5-turbo': 'gpt-3.5-turbo-0125' },
null,
2
)
)
}
>
{t('Example')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setModelMapping(JSON.stringify({}, null, 2))}
>
{t('Clear Mapping')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setModelMapping('')}
>
{t('No Change')}
</Button>
</div>
</div>
<Separator />
{/* Groups */}
<div className='space-y-2'>
<Label>
{t('Groups')}
<span className='text-muted-foreground ml-2 text-xs'>
{t("(Override all channels' groups)")}
</span>
</Label>
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
{availableGroups.map((group) => (
<GroupBadge
key={group}
group={group}
className={`cursor-pointer rounded-sm transition-opacity hover:opacity-70 ${
selectedGroups.includes(group) ? 'bg-muted/70 px-1' : ''
}`}
onClick={() => handleToggleGroup(group)}
/>
))}
</div>
</div>
</div>
</ScrollArea>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -28,6 +28,14 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
@@ -36,7 +44,6 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { Dialog } from '@/components/dialog'
import { fetchUpstreamModels, updateChannel } from '../../api'
import {
channelsQueryKeys,
@@ -358,153 +365,152 @@ export function FetchModelsDialog({
)
}
const showFooterActions =
!!(activeChannel || customFetcher) &&
!isFetching &&
(fetchedModels.length > 0 || removedModels.length > 0)
return (
<Dialog
open={open}
onOpenChange={handleClose}
title={t('Fetch Models')}
description={
activeChannel ? (
<>
{t('Channel:')} <strong>{activeChannel.name}</strong>
</>
) : channelName ? (
<>
{t('Channel:')} <strong>{channelName}</strong>
</>
) : (
t('Fetch available models from upstream')
)
}
contentClassName='max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
showFooterActions ? (
<>
<Button variant='outline' onClick={handleClose} disabled={isSaving}>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isSaving ? t('Saving...') : t('Save Models')}
</Button>
</>
) : null
}
>
{!activeChannel && !customFetcher ? (
<div className='text-muted-foreground py-8 text-center'>
{t('No channel selected')}
</div>
) : isFetching ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : fetchedModels.length === 0 && removedModels.length === 0 ? (
<div className='text-muted-foreground py-8 text-center'>
<p>{t('No models fetched yet.')}</p>
<Button
className='mt-4'
onClick={handleFetchModels}
disabled={isFetching}
>
{t('Fetch Models')}
</Button>
</div>
) : (
<>
<div className='space-y-4'>
{/* Search Bar */}
<div className='relative'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search models...')}
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className='pl-9'
/>
</div>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className='max-w-3xl'>
<DialogHeader>
<DialogTitle>{t('Fetch Models')}</DialogTitle>
<DialogDescription>
{activeChannel ? (
<>
{t('Fetch available models for:')}{' '}
<strong>{activeChannel.name}</strong>
</>
) : channelName ? (
<>
{t('Fetch available models for:')}{' '}
<strong>{channelName}</strong>
</>
) : (
t('Fetch available models from upstream')
)}
</DialogDescription>
</DialogHeader>
{/* Tabs for New vs Existing vs Removed */}
<Tabs
key={`${activeChannel?.id ?? 'custom'}-${fetchedModels.length}-${removedModels.length}`}
defaultValue={
newModels.length > 0
? 'new'
: removedModels.length > 0
? 'removed'
: 'existing'
}
{!activeChannel && !customFetcher ? (
<div className='text-muted-foreground py-8 text-center'>
{t('No channel selected')}
</div>
) : isFetching ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : fetchedModels.length === 0 && removedModels.length === 0 ? (
<div className='text-muted-foreground py-8 text-center'>
<p>{t('No models fetched yet.')}</p>
<Button
className='mt-4'
onClick={handleFetchModels}
disabled={isFetching}
>
<TabsList
className={`grid w-full ${removedModels.length > 0 ? 'grid-cols-3' : 'grid-cols-2'}`}
{t('Fetch Models')}
</Button>
</div>
) : (
<>
<div className='space-y-4'>
{/* Search Bar */}
<div className='relative'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search models...')}
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className='pl-9'
/>
</div>
{/* Tabs for New vs Existing vs Removed */}
<Tabs
key={`${activeChannel?.id ?? 'custom'}-${fetchedModels.length}-${removedModels.length}`}
defaultValue={
newModels.length > 0
? 'new'
: removedModels.length > 0
? 'removed'
: 'existing'
}
>
<TabsTrigger value='new' disabled={newModels.length === 0}>
{t('New Models ({{count}})', { count: newModels.length })}
</TabsTrigger>
<TabsTrigger
value='existing'
disabled={existingFilteredModels.length === 0}
<TabsList
className={`grid w-full ${removedModels.length > 0 ? 'grid-cols-3' : 'grid-cols-2'}`}
>
{t('Existing Models ({{count}})', {
count: existingFilteredModels.length,
})}
</TabsTrigger>
{removedModels.length > 0 && (
<TabsTrigger value='removed'>
{t('Removed Models ({{count}})', {
count: removedModels.length,
<TabsTrigger value='new' disabled={newModels.length === 0}>
{t('New Models ({{count}})', { count: newModels.length })}
</TabsTrigger>
<TabsTrigger
value='existing'
disabled={existingFilteredModels.length === 0}
>
{t('Existing Models ({{count}})', {
count: existingFilteredModels.length,
})}
</TabsTrigger>
)}
</TabsList>
{removedModels.length > 0 && (
<TabsTrigger value='removed'>
{t('Removed Models ({{count}})', {
count: removedModels.length,
})}
</TabsTrigger>
)}
</TabsList>
<TabsContent
value='new'
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(newModelsByCategory).map(
([category, models]) => renderModelCategory(category, models)
)}
</TabsContent>
<TabsContent
value='existing'
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(existingModelsByCategory).map(
([category, models]) => renderModelCategory(category, models)
)}
</TabsContent>
{removedModels.length > 0 && (
<TabsContent
value='removed'
value='new'
className='max-h-96 space-y-2 overflow-y-auto'
>
<p className='text-muted-foreground text-xs'>
{t(
'These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.'
)}
</p>
{renderModelCategory(t('Removed'), removedModels)}
{getSortedCategoryEntries(newModelsByCategory).map(
([category, models]) =>
renderModelCategory(category, models)
)}
</TabsContent>
)}
</Tabs>
{/* Selection Summary */}
<div className='bg-muted/50 rounded-lg border p-3 text-sm'>
{t('{{n}} model(s) selected', { n: selectedModels.length })}
<TabsContent
value='existing'
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(existingModelsByCategory).map(
([category, models]) =>
renderModelCategory(category, models)
)}
</TabsContent>
{removedModels.length > 0 && (
<TabsContent
value='removed'
className='max-h-96 space-y-2 overflow-y-auto'
>
<p className='text-muted-foreground text-xs'>
{t(
'These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.'
)}
</p>
{renderModelCategory(t('Removed'), removedModels)}
</TabsContent>
)}
</Tabs>
{/* Selection Summary */}
<div className='bg-muted/50 rounded-lg border p-3 text-sm'>
{t('{{n}} model(s) selected', { n: selectedModels.length })}
</div>
</div>
</div>
</>
)}
<DialogFooter>
<Button
variant='outline'
onClick={handleClose}
disabled={isSaving}
>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isSaving ? t('Saving...') : t('Save Models')}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
)
}
@@ -22,6 +22,13 @@ import { Loader2, RefreshCw, Trash2, Power, PowerOff } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
@@ -40,7 +47,6 @@ import {
TableRow,
} from '@/components/ui/table'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import {
getMultiKeyStatus,
@@ -222,217 +228,215 @@ export function MultiKeyManageDialog({
return (
<>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
{t('Multi-Key Management')}
<StatusBadge
label={currentRow.name}
variant='neutral'
copyable={false}
/>
{currentRow.channel_info?.multi_key_mode && (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='flex max-h-[90vh] max-w-5xl flex-col'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
{t('Multi-Key Management')}
<StatusBadge
label={
currentRow.channel_info.multi_key_mode === 'random'
? t('Random')
: t('Polling')
}
label={currentRow.name}
variant='neutral'
copyable={false}
/>
)}
</>
}
description={t(
'Manage multi-key status and configuration for this channel'
)}
contentClassName='flex max-h-[90vh] max-w-5xl flex-col'
titleClassName='flex items-center gap-2'
contentHeight='min(72vh, 720px)'
bodyClassName='space-y-4'
>
<div className='flex min-h-0 flex-1 flex-col space-y-4 overflow-hidden'>
{/* Statistics */}
<div className='grid shrink-0 grid-cols-3 gap-3'>
<StatisticsCard
label={t('Enabled')}
count={enabledCount}
total={total}
/>
<StatisticsCard
label={t('Manual Disabled')}
count={manualDisabledCount}
total={total}
/>
<StatisticsCard
label={t('Auto Disabled')}
count={autoDisabledCount}
total={total}
/>
</div>
<Separator className='shrink-0' />
{/* Toolbar */}
<div className='flex shrink-0 items-center justify-between'>
<Select
items={[
...MULTI_KEY_FILTER_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
})),
]}
value={statusFilter === null ? 'all' : statusFilter.toString()}
onValueChange={(v) => v !== null && handleStatusFilterChange(v)}
>
<SelectTrigger className='w-40'>
<SelectValue placeholder={t('All Status')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{MULTI_KEY_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => loadKeyStatus()}
disabled={isLoading}
>
<RefreshCw className='h-4 w-4' />
</Button>
{manualDisabledCount + autoDisabledCount > 0 && (
<Button
variant='default'
size='sm'
onClick={() => setConfirmAction({ type: 'enable-all' })}
>
<Power className='mr-2 h-4 w-4' />
{t('Enable All')}
</Button>
{currentRow.channel_info?.multi_key_mode && (
<StatusBadge
label={
currentRow.channel_info.multi_key_mode === 'random'
? t('Random')
: t('Polling')
}
variant='neutral'
copyable={false}
/>
)}
</DialogTitle>
<DialogDescription>
{t('Manage multi-key status and configuration for this channel')}
</DialogDescription>
</DialogHeader>
{enabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() => setConfirmAction({ type: 'disable-all' })}
>
<PowerOff className='mr-2 h-4 w-4' />
{t('Disable All')}
</Button>
)}
{autoDisabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() => setConfirmAction({ type: 'delete-disabled' })}
>
<Trash2 className='mr-2 h-4 w-4' />
{t('Delete Auto-Disabled')}
</Button>
)}
<div className='flex min-h-0 flex-1 flex-col space-y-4 overflow-hidden'>
{/* Statistics */}
<div className='grid shrink-0 grid-cols-3 gap-3'>
<StatisticsCard
label={t('Enabled')}
count={enabledCount}
total={total}
/>
<StatisticsCard
label={t('Manual Disabled')}
count={manualDisabledCount}
total={total}
/>
<StatisticsCard
label={t('Auto Disabled')}
count={autoDisabledCount}
total={total}
/>
</div>
</div>
{/* Table */}
<div className='min-h-0 flex-1 overflow-auto rounded-md border'>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : keys.length === 0 ? (
<div className='text-muted-foreground py-12 text-center'>
{t('No keys found')}
</div>
) : (
<div className='min-w-[800px]'>
<Table>
<TableHeader>
<TableRow>
<TableHead className='w-20'>{t('Index')}</TableHead>
<TableHead className='w-32'>{t('Status')}</TableHead>
<TableHead className='min-w-[200px]'>
{t('Disabled Reason')}
</TableHead>
<TableHead className='w-44'>
{t('Disabled Time')}
</TableHead>
<TableHead className='w-44 text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{keys.map((key) => (
<TableRow key={key.index}>
<TableCell className='font-mono text-sm'>
#{key.index + 1}
</TableCell>
<TableCell>{renderStatusBadge(key.status)}</TableCell>
<TableCell className='max-w-xs truncate text-sm'>
{key.reason || '-'}
</TableCell>
<TableCell className='text-muted-foreground text-sm'>
{formatKeyTimestamp(key.disabled_time)}
</TableCell>
<TableCell>
<MultiKeyTableRowActions
keyIndex={key.index}
status={key.status}
onAction={setConfirmAction}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
<Separator className='shrink-0' />
{/* Pagination */}
{totalPages > 1 && (
{/* Toolbar */}
<div className='flex shrink-0 items-center justify-between'>
<div className='text-muted-foreground text-sm'>
{t('Page {{current}} of {{total}}', {
current: currentPage,
total: totalPages,
})}
</div>
<div className='flex gap-2'>
<Select
items={[
...MULTI_KEY_FILTER_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
})),
]}
value={statusFilter === null ? 'all' : statusFilter.toString()}
onValueChange={(v) => v !== null && handleStatusFilterChange(v)}
>
<SelectTrigger className='w-40'>
<SelectValue placeholder={t('All Status')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{MULTI_KEY_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || isLoading}
onClick={() => loadKeyStatus()}
disabled={isLoading}
>
{t('Previous')}
</Button>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages || isLoading}
>
{t('Next')}
<RefreshCw className='h-4 w-4' />
</Button>
{manualDisabledCount + autoDisabledCount > 0 && (
<Button
variant='default'
size='sm'
onClick={() => setConfirmAction({ type: 'enable-all' })}
>
<Power className='mr-2 h-4 w-4' />
{t('Enable All')}
</Button>
)}
{enabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() => setConfirmAction({ type: 'disable-all' })}
>
<PowerOff className='mr-2 h-4 w-4' />
{t('Disable All')}
</Button>
)}
{autoDisabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() =>
setConfirmAction({ type: 'delete-disabled' })
}
>
<Trash2 className='mr-2 h-4 w-4' />
{t('Delete Auto-Disabled')}
</Button>
)}
</div>
</div>
)}
</div>
{/* Table */}
<div className='min-h-0 flex-1 overflow-auto rounded-md border'>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : keys.length === 0 ? (
<div className='text-muted-foreground py-12 text-center'>
{t('No keys found')}
</div>
) : (
<div className='min-w-[800px]'>
<Table>
<TableHeader>
<TableRow>
<TableHead className='w-20'>{t('Index')}</TableHead>
<TableHead className='w-32'>{t('Status')}</TableHead>
<TableHead className='min-w-[200px]'>
{t('Disabled Reason')}
</TableHead>
<TableHead className='w-44'>
{t('Disabled Time')}
</TableHead>
<TableHead className='w-44 text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{keys.map((key) => (
<TableRow key={key.index}>
<TableCell className='font-mono text-sm'>
#{key.index + 1}
</TableCell>
<TableCell>{renderStatusBadge(key.status)}</TableCell>
<TableCell className='max-w-xs truncate text-sm'>
{key.reason || '-'}
</TableCell>
<TableCell className='text-muted-foreground text-sm'>
{formatKeyTimestamp(key.disabled_time)}
</TableCell>
<TableCell>
<MultiKeyTableRowActions
keyIndex={key.index}
status={key.status}
onAction={setConfirmAction}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className='flex shrink-0 items-center justify-between'>
<div className='text-muted-foreground text-sm'>
{t('Page {{current}} of {{total}}', {
current: currentPage,
total: totalPages,
})}
</div>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || isLoading}
>
{t('Previous')}
</Button>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages || isLoading}
>
{t('Next')}
</Button>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
{/* Confirmation Dialog */}
@@ -34,11 +34,18 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator'
import { Dialog } from '@/components/dialog'
import {
deleteOllamaModel,
fetchModels as fetchModelsFromEndpoint,
@@ -368,203 +375,203 @@ export function OllamaModelsDialog({
if (!open) return null
return (
<Dialog
open={open}
onOpenChange={close}
title={t('Ollama Models')}
description={
<>
{t('Manage local models for:')} <strong>{currentRow?.name}</strong>
</>
}
contentClassName='sm:max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<Button variant='outline' onClick={close}>
{t('Close')}
</Button>
}
>
{!isOllamaChannel ? (
<div className='text-muted-foreground py-8 text-center'>
{t('This channel is not an Ollama channel.')}
</div>
) : (
<div className='space-y-4 py-2 pr-1'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
<div className='flex-1 space-y-2'>
<Label htmlFor='ollama-pull'>{t('Pull model')}</Label>
<Dialog open={open} onOpenChange={close}>
<DialogContent className='max-h-[90vh] overflow-hidden sm:max-w-3xl'>
<DialogHeader>
<DialogTitle>{t('Ollama Models')}</DialogTitle>
<DialogDescription>
{t('Manage local models for:')} <strong>{currentRow?.name}</strong>
</DialogDescription>
</DialogHeader>
{!isOllamaChannel ? (
<div className='text-muted-foreground py-8 text-center'>
{t('This channel is not an Ollama channel.')}
</div>
) : (
<div className='max-h-[78vh] space-y-4 overflow-y-auto py-2 pr-1'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
<div className='flex-1 space-y-2'>
<Label htmlFor='ollama-pull'>{t('Pull model')}</Label>
<div className='flex gap-2'>
<Input
id='ollama-pull'
placeholder={t('e.g. llama3.1:8b')}
value={pullName}
onChange={(e) => setPullName(e.target.value)}
disabled={!channelId || isPulling}
/>
<Button
onClick={() => void pullModel()}
disabled={!channelId || isPulling}
>
{isPulling ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{t('Pulling...')}
</>
) : (
<>
<Download className='mr-2 h-4 w-4' />
{t('Pull')}
</>
)}
</Button>
</div>
{pullProgress && (
<div className='space-y-2'>
<div className='text-muted-foreground text-xs'>
{t('Status:')} {String(pullProgress.status || '-')}
</div>
<Progress
value={
typeof pullProgress.completed === 'number' &&
typeof pullProgress.total === 'number' &&
pullProgress.total > 0
? Math.min(
100,
Math.round(
(pullProgress.completed / pullProgress.total) *
100
)
)
: 0
}
/>
</div>
)}
</div>
<div className='flex gap-2'>
<Input
id='ollama-pull'
placeholder={t('e.g. llama3.1:8b')}
value={pullName}
onChange={(e) => setPullName(e.target.value)}
disabled={!channelId || isPulling}
/>
<Button
onClick={() => void pullModel()}
disabled={!channelId || isPulling}
variant='outline'
onClick={() => void fetchOllamaModels()}
disabled={!channelId || isFetching}
>
{isPulling ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{t('Pulling...')}
</>
{isFetching ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<>
<Download className='mr-2 h-4 w-4' />
{t('Pull')}
</>
<RefreshCw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
</div>
{pullProgress && (
<div className='space-y-2'>
<div className='text-muted-foreground text-xs'>
{t('Status:')} {String(pullProgress.status || '-')}
</div>
<Progress
value={
typeof pullProgress.completed === 'number' &&
typeof pullProgress.total === 'number' &&
pullProgress.total > 0
? Math.min(
100,
Math.round(
(pullProgress.completed / pullProgress.total) *
100
)
)
: 0
}
</div>
<Separator />
<div className='space-y-3'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='text-sm font-medium'>{t('Local models')}</p>
<p className='text-muted-foreground text-xs'>
{t('Select models and apply to channel models list.')}
</p>
</div>
<div className='relative sm:w-72'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search models...')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className='pl-9'
/>
</div>
)}
</div>
<div className='flex gap-2'>
<Button
variant='outline'
onClick={() => void fetchOllamaModels()}
disabled={!channelId || isFetching}
>
{isFetching ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
</div>
</div>
<Separator />
<div className='space-y-3'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='text-sm font-medium'>{t('Local models')}</p>
<p className='text-muted-foreground text-xs'>
{t('Select models and apply to channel models list.')}
</p>
</div>
<div className='relative sm:w-72'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search models...')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className='pl-9'
/>
<div className='flex flex-wrap gap-2'>
<Button variant='outline' size='sm' onClick={selectAllFiltered}>
{t('Select all (filtered)')}
</Button>
<Button variant='outline' size='sm' onClick={clearSelection}>
{t('Clear selection')}
</Button>
<Button
size='sm'
onClick={() => void applySelection('append')}
disabled={!selected.length}
>
{t('Append to channel')}
</Button>
<Button
variant='secondary'
size='sm'
onClick={() => void applySelection('replace')}
disabled={!selected.length}
>
{t('Replace channel models')}
</Button>
</div>
</div>
<div className='flex flex-wrap gap-2'>
<Button variant='outline' size='sm' onClick={selectAllFiltered}>
{t('Select all (filtered)')}
</Button>
<Button variant='outline' size='sm' onClick={clearSelection}>
{t('Clear selection')}
</Button>
<Button
size='sm'
onClick={() => void applySelection('append')}
disabled={!selected.length}
>
{t('Append to channel')}
</Button>
<Button
variant='secondary'
size='sm'
onClick={() => void applySelection('replace')}
disabled={!selected.length}
>
{t('Replace channel models')}
</Button>
</div>
<div className='overflow-hidden rounded-md border'>
<div className='max-h-[420px] overflow-y-auto'>
{filteredModels.length === 0 ? (
<div className='text-muted-foreground p-6 text-center text-sm'>
{t('No models found.')}
</div>
) : (
<div className='divide-y'>
{filteredModels.map((m) => {
const checked = selected.includes(m.id)
return (
<div
key={m.id}
className='flex items-center justify-between gap-3 p-3'
>
<div className='flex min-w-0 items-start gap-3'>
<Checkbox
checked={checked}
onCheckedChange={(v) => toggleSelected(m.id, !!v)}
aria-label={`Select model ${m.id}`}
/>
<div className='min-w-0'>
<div className='truncate font-mono text-sm'>
{m.id}
</div>
<div className='text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 text-xs'>
<span>
{t('Size:')} {formatBytes(m.size)}
</span>
{m.digest && (
<span className='truncate'>
{t('Digest:')} {String(m.digest)}
<div className='overflow-hidden rounded-md border'>
<div className='max-h-[420px] overflow-y-auto'>
{filteredModels.length === 0 ? (
<div className='text-muted-foreground p-6 text-center text-sm'>
{t('No models found.')}
</div>
) : (
<div className='divide-y'>
{filteredModels.map((m) => {
const checked = selected.includes(m.id)
return (
<div
key={m.id}
className='flex items-center justify-between gap-3 p-3'
>
<div className='flex min-w-0 items-start gap-3'>
<Checkbox
checked={checked}
onCheckedChange={(v) =>
toggleSelected(m.id, !!v)
}
aria-label={`Select model ${m.id}`}
/>
<div className='min-w-0'>
<div className='truncate font-mono text-sm'>
{m.id}
</div>
<div className='text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 text-xs'>
<span>
{t('Size:')} {formatBytes(m.size)}
</span>
)}
{m.digest && (
<span className='truncate'>
{t('Digest:')} {String(m.digest)}
</span>
)}
</div>
</div>
</div>
</div>
<Button
variant='ghost'
size='sm'
className='text-destructive hover:text-destructive'
onClick={() => {
setDeleteTarget(m.id)
setDeleteOpen(true)
}}
disabled={!channelId}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
)
})}
</div>
)}
<Button
variant='ghost'
size='sm'
className='text-destructive hover:text-destructive'
onClick={() => {
setDeleteTarget(m.id)
setDeleteOpen(true)
}}
disabled={!channelId}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
</div>
</div>
)}
)}
<DialogFooter>
<Button variant='outline' onClick={close}>
{t('Close')}
</Button>
</DialogFooter>
</DialogContent>
<AlertDialog
open={deleteOpen}
@@ -43,6 +43,14 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
@@ -55,7 +63,6 @@ import {
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
// ---------------------------------------------------------------------------
// Types
@@ -1694,20 +1701,356 @@ export function ParamOverrideEditorDialog(
// ---------------------------------------------------------------------------
return (
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={t('Parameter Override')}
description={t(
'Create request parameter override rules with a visual editor or raw JSON.'
)}
contentClassName='flex max-h-[90vh] flex-col gap-0 p-0 sm:max-w-5xl'
headerClassName='border-b px-6 py-4'
footerClassName='border-t px-6 py-4'
contentHeight='min(72vh, 720px)'
bodyClassName='space-y-4'
footer={
<>
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='flex max-h-[90vh] flex-col gap-0 p-0 sm:max-w-5xl'>
<DialogHeader className='border-b px-6 py-4'>
<DialogTitle>{t('Parameter Override')}</DialogTitle>
<DialogDescription>
{t(
'Create request parameter override rules with a visual editor or raw JSON.'
)}
</DialogDescription>
</DialogHeader>
{/* Toolbar */}
<div className='bg-muted/30 border-b px-4 py-3'>
<div className='flex flex-wrap items-center gap-2'>
<span className='text-muted-foreground text-xs font-medium'>
{t('Mode')}
</span>
<Button
type='button'
variant={editMode === 'visual' ? 'default' : 'outline'}
size='sm'
onClick={switchToVisualMode}
>
{t('Visual')}
</Button>
<Button
type='button'
variant={editMode === 'json' ? 'default' : 'outline'}
size='sm'
onClick={switchToJsonMode}
>
{t('JSON Text')}
</Button>
<div className='bg-border mx-1 h-5 w-px' />
<span className='text-muted-foreground text-xs font-medium'>
{t('Template')}
</span>
<Select
items={[
...templatePresetOptions.map((o) => ({
value: o.value,
label: t(o.label),
})),
]}
value={templatePresetKey}
onValueChange={(v) =>
setTemplatePresetKey(v || 'operations_default')
}
>
<SelectTrigger className='h-8 w-[220px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{templatePresetOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => fillTemplate('fill')}
>
{t('Fill Template')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => fillTemplate('append')}
>
{t('Append Template')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={resetEditorState}
>
{t('Reset')}
</Button>
</div>
</div>
{/* Content */}
<div className='min-h-0 flex-1 overflow-hidden'>
{editMode === 'visual' ? (
visualMode === 'legacy' ? (
<div className='p-4'>
<p className='text-muted-foreground mb-2 text-sm'>
{t('Legacy Format (JSON Object)')}
</p>
<Textarea
value={legacyValue}
onChange={(e) => setLegacyValue(e.target.value)}
placeholder={JSON.stringify(LEGACY_TEMPLATE, null, 2)}
rows={14}
className='font-mono text-xs'
/>
<p className='text-muted-foreground mt-2 text-xs'>
{t(
'Edit JSON object directly. Suitable for simple parameter overrides.'
)}
</p>
</div>
) : (
<div className='flex h-full'>
{/* Left sidebar */}
<div className='flex w-[280px] flex-shrink-0 flex-col border-r'>
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<span className='text-sm font-medium'>{t('Rules')}</span>
<Badge variant='secondary'>
{operationCount}/{operations.length}
</Badge>
</div>
<Button
type='button'
variant='ghost'
size='sm'
onClick={addOperation}
>
<Plus className='h-4 w-4' />
</Button>
</div>
{topOperationModes.length > 0 && (
<div className='flex flex-wrap gap-1 border-b px-3 py-2'>
{topOperationModes.map(([mode, count]) => (
<span
key={`mode_stat_${mode}`}
className={cn(
'inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium',
getModeTagTailwind(mode)
)}
>
{t(OPERATION_MODE_LABEL_MAP[mode] || mode)} · {count}
</span>
))}
</div>
)}
<div className='px-3 py-2'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-3.5 w-3.5' />
<Input
value={operationSearch}
onChange={(e) => setOperationSearch(e.target.value)}
placeholder={t('Search rules...')}
className='h-8 pl-8 text-xs'
/>
</div>
</div>
<ScrollArea className='flex-1'>
<div className='flex flex-col gap-1 px-3 pb-3'>
{filteredOperations.length === 0 ? (
<p className='text-muted-foreground py-4 text-center text-xs'>
{t('No matching rules')}
</p>
) : (
filteredOperations.map((operation) => {
const index = operations.findIndex(
(o) => o.id === operation.id
)
const isActive = operation.id === selectedOperationId
const isDragging = operation.id === draggedOperationId
const isDropTarget =
operation.id === dragOverOperationId &&
draggedOperationId !== '' &&
draggedOperationId !== operation.id
return (
<div
key={operation.id}
role='button'
tabIndex={0}
draggable={operations.length > 1}
onClick={() =>
setSelectedOperationId(operation.id)
}
onDragStart={(e) =>
handleDragStart(e, operation.id)
}
onDragOver={(e) =>
handleDragOver(e, operation.id)
}
onDrop={(e) => handleDrop(e, operation.id)}
onDragEnd={resetDragState}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setSelectedOperationId(operation.id)
}
}}
className={cn(
'cursor-pointer rounded-lg border p-2.5 transition-colors',
isActive
? 'border-primary bg-primary/5'
: 'hover:bg-muted/50',
isDragging && 'opacity-50',
isDropTarget &&
dragOverPosition === 'before' &&
'border-t-primary border-t-2',
isDropTarget &&
dragOverPosition === 'after' &&
'border-b-primary border-b-2'
)}
>
<div className='flex items-start gap-2'>
<GripVertical
className={cn(
'text-muted-foreground mt-0.5 h-3.5 w-3.5 flex-shrink-0',
operations.length > 1
? 'cursor-grab'
: 'cursor-default'
)}
/>
<div className='min-w-0 flex-1'>
<div className='flex items-center justify-between gap-1'>
<span className='text-xs font-semibold'>
#{index + 1}
</span>
<Badge
variant='outline'
className='text-[10px]'
>
{operation.conditions.length}
</Badge>
</div>
<p className='text-muted-foreground mt-0.5 line-clamp-1 text-[11px]'>
{getOperationSummary(operation, index)}
</p>
{operation.description.trim() && (
<p className='text-muted-foreground mt-0.5 line-clamp-2 text-[10px]'>
{operation.description}
</p>
)}
<span
className={cn(
'mt-1 inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium',
getModeTagTailwind(
operation.mode || 'set'
)
)}
>
{t(
OPERATION_MODE_LABEL_MAP[
operation.mode || 'set'
] ||
operation.mode ||
'set'
)}
</span>
</div>
</div>
</div>
)
})
)}
</div>
</ScrollArea>
</div>
{/* Right panel - Rule editor */}
<div className='flex min-w-0 flex-1 flex-col overflow-y-auto'>
{selectedOperation ? (
<RuleEditor
operation={selectedOperation}
operationIndex={selectedOperationIndex}
operations={operations}
returnErrorDraft={returnErrorDraft}
pruneObjectsDraft={pruneObjectsDraft}
expandedConditions={expandedConditions}
setExpandedConditions={setExpandedConditions}
updateOperation={updateOperation}
duplicateOperation={duplicateOperation}
removeOperation={removeOperation}
addCondition={addCondition}
updateCondition={updateCondition}
removeCondition={removeCondition}
updateReturnErrorDraft={updateReturnErrorDraft}
updatePruneObjectsDraft={updatePruneObjectsDraft}
addPruneRule={addPruneRule}
updatePruneRule={updatePruneRule}
removePruneRule={removePruneRule}
expandAllConditions={expandAllConditions}
collapseAllConditions={collapseAllConditions}
/>
) : (
<div className='flex flex-1 items-center justify-center'>
<p className='text-muted-foreground text-sm'>
{t('Select a rule to edit.')}
</p>
</div>
)}
{visualValidationError && (
<div className='border-t px-4 py-2'>
<p className='text-destructive text-xs'>
{visualValidationError}
</p>
</div>
)}
</div>
</div>
)
) : (
/* JSON mode */
<div className='p-4'>
<div className='mb-2 flex items-center gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={formatJson}
>
{t('Format')}
</Button>
<span className='text-muted-foreground text-xs'>
{t('Advanced text editing')}
</span>
</div>
<Textarea
value={jsonText}
onChange={(e) => handleJsonChange(e.target.value)}
placeholder={JSON.stringify(OPERATION_TEMPLATE, null, 2)}
rows={20}
className='font-mono text-xs'
/>
<p className='text-muted-foreground mt-2 text-xs'>
{t(
'Edit JSON text directly. Format will be validated on save.'
)}
</p>
{jsonError && (
<p className='text-destructive mt-1 text-xs'>{jsonError}</p>
)}
</div>
)}
</div>
{/* Footer */}
<DialogFooter className='border-t px-6 py-4'>
<Button
type='button'
variant='outline'
@@ -1718,337 +2061,8 @@ export function ParamOverrideEditorDialog(
<Button type='button' onClick={handleSave}>
{t('Save')}
</Button>
</>
}
>
{/* Toolbar */}
<div className='bg-muted/30 border-b px-4 py-3'>
<div className='flex flex-wrap items-center gap-2'>
<span className='text-muted-foreground text-xs font-medium'>
{t('Mode')}
</span>
<Button
type='button'
variant={editMode === 'visual' ? 'default' : 'outline'}
size='sm'
onClick={switchToVisualMode}
>
{t('Visual')}
</Button>
<Button
type='button'
variant={editMode === 'json' ? 'default' : 'outline'}
size='sm'
onClick={switchToJsonMode}
>
{t('JSON Text')}
</Button>
<div className='bg-border mx-1 h-5 w-px' />
<span className='text-muted-foreground text-xs font-medium'>
{t('Template')}
</span>
<Select
items={[
...templatePresetOptions.map((o) => ({
value: o.value,
label: t(o.label),
})),
]}
value={templatePresetKey}
onValueChange={(v) =>
setTemplatePresetKey(v || 'operations_default')
}
>
<SelectTrigger className='h-8 w-[220px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{templatePresetOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => fillTemplate('fill')}
>
{t('Fill Template')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => fillTemplate('append')}
>
{t('Append Template')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={resetEditorState}
>
{t('Reset')}
</Button>
</div>
</div>
{/* Content */}
<div className='min-h-0 flex-1 overflow-hidden'>
{editMode === 'visual' ? (
visualMode === 'legacy' ? (
<div className='p-4'>
<p className='text-muted-foreground mb-2 text-sm'>
{t('Legacy Format (JSON Object)')}
</p>
<Textarea
value={legacyValue}
onChange={(e) => setLegacyValue(e.target.value)}
placeholder={JSON.stringify(LEGACY_TEMPLATE, null, 2)}
rows={14}
className='font-mono text-xs'
/>
<p className='text-muted-foreground mt-2 text-xs'>
{t(
'Edit JSON object directly. Suitable for simple parameter overrides.'
)}
</p>
</div>
) : (
<div className='flex h-full'>
{/* Left sidebar */}
<div className='flex w-[280px] flex-shrink-0 flex-col border-r'>
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<span className='text-sm font-medium'>{t('Rules')}</span>
<Badge variant='secondary'>
{operationCount}/{operations.length}
</Badge>
</div>
<Button
type='button'
variant='ghost'
size='sm'
onClick={addOperation}
>
<Plus className='h-4 w-4' />
</Button>
</div>
{topOperationModes.length > 0 && (
<div className='flex flex-wrap gap-1 border-b px-3 py-2'>
{topOperationModes.map(([mode, count]) => (
<span
key={`mode_stat_${mode}`}
className={cn(
'inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium',
getModeTagTailwind(mode)
)}
>
{t(OPERATION_MODE_LABEL_MAP[mode] || mode)} · {count}
</span>
))}
</div>
)}
<div className='px-3 py-2'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-3.5 w-3.5' />
<Input
value={operationSearch}
onChange={(e) => setOperationSearch(e.target.value)}
placeholder={t('Search rules...')}
className='h-8 pl-8 text-xs'
/>
</div>
</div>
<ScrollArea className='flex-1'>
<div className='flex flex-col gap-1 px-3 pb-3'>
{filteredOperations.length === 0 ? (
<p className='text-muted-foreground py-4 text-center text-xs'>
{t('No matching rules')}
</p>
) : (
filteredOperations.map((operation) => {
const index = operations.findIndex(
(o) => o.id === operation.id
)
const isActive = operation.id === selectedOperationId
const isDragging = operation.id === draggedOperationId
const isDropTarget =
operation.id === dragOverOperationId &&
draggedOperationId !== '' &&
draggedOperationId !== operation.id
return (
<div
key={operation.id}
role='button'
tabIndex={0}
draggable={operations.length > 1}
onClick={() => setSelectedOperationId(operation.id)}
onDragStart={(e) =>
handleDragStart(e, operation.id)
}
onDragOver={(e) => handleDragOver(e, operation.id)}
onDrop={(e) => handleDrop(e, operation.id)}
onDragEnd={resetDragState}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setSelectedOperationId(operation.id)
}
}}
className={cn(
'cursor-pointer rounded-lg border p-2.5 transition-colors',
isActive
? 'border-primary bg-primary/5'
: 'hover:bg-muted/50',
isDragging && 'opacity-50',
isDropTarget &&
dragOverPosition === 'before' &&
'border-t-primary border-t-2',
isDropTarget &&
dragOverPosition === 'after' &&
'border-b-primary border-b-2'
)}
>
<div className='flex items-start gap-2'>
<GripVertical
className={cn(
'text-muted-foreground mt-0.5 h-3.5 w-3.5 flex-shrink-0',
operations.length > 1
? 'cursor-grab'
: 'cursor-default'
)}
/>
<div className='min-w-0 flex-1'>
<div className='flex items-center justify-between gap-1'>
<span className='text-xs font-semibold'>
#{index + 1}
</span>
<Badge
variant='outline'
className='text-[10px]'
>
{operation.conditions.length}
</Badge>
</div>
<p className='text-muted-foreground mt-0.5 line-clamp-1 text-[11px]'>
{getOperationSummary(operation, index)}
</p>
{operation.description.trim() && (
<p className='text-muted-foreground mt-0.5 line-clamp-2 text-[10px]'>
{operation.description}
</p>
)}
<span
className={cn(
'mt-1 inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium',
getModeTagTailwind(operation.mode || 'set')
)}
>
{t(
OPERATION_MODE_LABEL_MAP[
operation.mode || 'set'
] ||
operation.mode ||
'set'
)}
</span>
</div>
</div>
</div>
)
})
)}
</div>
</ScrollArea>
</div>
{/* Right panel - Rule editor */}
<div className='flex min-w-0 flex-1 flex-col overflow-y-auto'>
{selectedOperation ? (
<RuleEditor
operation={selectedOperation}
operationIndex={selectedOperationIndex}
operations={operations}
returnErrorDraft={returnErrorDraft}
pruneObjectsDraft={pruneObjectsDraft}
expandedConditions={expandedConditions}
setExpandedConditions={setExpandedConditions}
updateOperation={updateOperation}
duplicateOperation={duplicateOperation}
removeOperation={removeOperation}
addCondition={addCondition}
updateCondition={updateCondition}
removeCondition={removeCondition}
updateReturnErrorDraft={updateReturnErrorDraft}
updatePruneObjectsDraft={updatePruneObjectsDraft}
addPruneRule={addPruneRule}
updatePruneRule={updatePruneRule}
removePruneRule={removePruneRule}
expandAllConditions={expandAllConditions}
collapseAllConditions={collapseAllConditions}
/>
) : (
<div className='flex flex-1 items-center justify-center'>
<p className='text-muted-foreground text-sm'>
{t('Select a rule to edit.')}
</p>
</div>
)}
{visualValidationError && (
<div className='border-t px-4 py-2'>
<p className='text-destructive text-xs'>
{visualValidationError}
</p>
</div>
)}
</div>
</div>
)
) : (
/* JSON mode */
<div className='p-4'>
<div className='mb-2 flex items-center gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={formatJson}
>
{t('Format')}
</Button>
<span className='text-muted-foreground text-xs'>
{t('Advanced text editing')}
</span>
</div>
<Textarea
value={jsonText}
onChange={(e) => handleJsonChange(e.target.value)}
placeholder={JSON.stringify(OPERATION_TEMPLATE, null, 2)}
rows={20}
className='font-mono text-xs'
/>
<p className='text-muted-foreground mt-2 text-xs'>
{t('Edit JSON text directly. Format will be validated on save.')}
</p>
{jsonError && (
<p className='text-destructive mt-1 text-xs'>{jsonError}</p>
)}
</div>
)}
</div>
{/* Footer */}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -21,9 +21,16 @@ import { AlertTriangle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
interface StatusCodeRiskDialogProps {
open: boolean
@@ -77,22 +84,73 @@ export function StatusCodeRiskDialog({
}
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
<AlertTriangle className='h-5 w-5' />
{t('High-risk operation confirmation')}
</>
}
description={t('High-risk status code retry risk disclaimer')}
contentClassName='max-w-lg'
titleClassName='text-destructive flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-w-lg'>
<DialogHeader>
<DialogTitle className='text-destructive flex items-center gap-2'>
<AlertTriangle className='h-5 w-5' />
{t('High-risk operation confirmation')}
</DialogTitle>
<DialogDescription>
{t('High-risk status code retry risk disclaimer')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
{detailItems.length > 0 && (
<div className='border-destructive/30 bg-destructive/5 rounded-lg border p-3'>
<p className='mb-2 text-sm font-medium'>
{t('Detected high-risk status code redirect rules')}
</p>
<ul className='list-inside list-disc text-sm'>
{detailItems.map((item) => (
<li key={item} className='font-mono text-xs'>
{item}
</li>
))}
</ul>
</div>
)}
<div className='space-y-2'>
{CHECKLIST_KEYS.map((key, idx) => (
<div key={key} className='flex items-start gap-2'>
<Checkbox
id={`risk-check-${idx}`}
checked={checkedItems.has(idx)}
onCheckedChange={() => toggleCheck(idx)}
/>
<Label
htmlFor={`risk-check-${idx}`}
className='text-sm leading-tight'
>
{t(key)}
</Label>
</div>
))}
</div>
<div className='space-y-1.5'>
<Label className='text-sm'>
{t('Action confirmation')}:{' '}
<code className='bg-muted rounded px-1 text-xs'>
{requiredText}
</code>
</Label>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={t('High-risk status code retry input placeholder')}
/>
{confirmText && !textMatches && (
<p className='text-destructive text-xs'>
{t('High-risk status code retry input mismatch')}
</p>
)}
</div>
</div>
<DialogFooter>
<Button variant='outline' onClick={handleCancel}>
{t('Cancel')}
</Button>
@@ -103,62 +161,8 @@ export function StatusCodeRiskDialog({
>
{t('I confirm enabling high-risk retry')}
</Button>
</>
}
>
<div className='space-y-4'>
{detailItems.length > 0 && (
<div className='border-destructive/30 bg-destructive/5 rounded-lg border p-3'>
<p className='mb-2 text-sm font-medium'>
{t('Detected high-risk status code redirect rules')}
</p>
<ul className='list-inside list-disc text-sm'>
{detailItems.map((item) => (
<li key={item} className='font-mono text-xs'>
{item}
</li>
))}
</ul>
</div>
)}
<div className='space-y-2'>
{CHECKLIST_KEYS.map((key, idx) => (
<div key={key} className='flex items-start gap-2'>
<Checkbox
id={`risk-check-${idx}`}
checked={checkedItems.has(idx)}
onCheckedChange={() => toggleCheck(idx)}
/>
<Label
htmlFor={`risk-check-${idx}`}
className='text-sm leading-tight'
>
{t(key)}
</Label>
</div>
))}
</div>
<div className='space-y-1.5'>
<Label className='text-sm'>
{t('Action confirmation')}:{' '}
<code className='bg-muted rounded px-1 text-xs'>
{requiredText}
</code>
</Label>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={t('High-risk status code retry input placeholder')}
/>
{confirmText && !textMatches && (
<p className='text-destructive text-xs'>
{t('High-risk status code retry input mismatch')}
</p>
)}
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -23,11 +23,18 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { MultiSelect } from '@/components/multi-select'
import {
getTagModels,
@@ -183,118 +190,115 @@ export function TagBatchEditDialog({
if (!currentTag) return null
return (
<Dialog
open={open}
onOpenChange={handleClose}
title={t('Batch Edit by Tag')}
description={
<>
{t('Edit all channels with tag:')}
<strong>{currentTag}</strong>
</>
}
contentClassName='max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
!isLoading ? (
<>
<Button variant='outline' onClick={handleClose} disabled={isSaving}>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{isSaving ? t('Saving...') : t('Save Changes')}
</Button>
</>
) : null
}
>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : (
<>
<div className='space-y-4 py-4'>
<Alert>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
{t(
'All edits are overwrite operations. Leave fields empty to keep current values unchanged.'
)}
</AlertDescription>
</Alert>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className='max-h-[90vh] max-w-2xl overflow-y-auto'>
<DialogHeader>
<DialogTitle>{t('Batch Edit by Tag')}</DialogTitle>
<DialogDescription>
{t('Edit all channels with tag:')} <strong>{currentTag}</strong>
</DialogDescription>
</DialogHeader>
{/* Tag Name */}
<div className='space-y-2'>
<Label htmlFor='new-tag'>{t('Tag Name')}</Label>
<Input
id='new-tag'
placeholder={t(
'Enter new tag name (leave empty to disband tag)'
)}
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
disabled={isSaving}
/>
<p className='text-muted-foreground text-xs'>
{t('Leave empty to disband the tag')}
</p>
</div>
{/* Models */}
<div className='space-y-2'>
<Label htmlFor='models'>{t('Models')}</Label>
<Textarea
id='models'
placeholder={t(
'Comma-separated model names (leave empty to keep current)'
)}
value={models}
onChange={(e) => setModels(e.target.value)}
disabled={isSaving}
rows={3}
/>
<p className='text-muted-foreground text-xs'>
{t(
'Current models for the longest channel in this tag. May not include all models from all channels.'
)}
</p>
</div>
{/* Model Mapping */}
<div className='space-y-2'>
<Label htmlFor='model-mapping'>{t('Model Mapping')}</Label>
<ModelMappingEditor
value={modelMapping}
onChange={setModelMapping}
disabled={isSaving}
/>
</div>
{/* Groups */}
<div className='space-y-2'>
<Label htmlFor='groups'>{t('Groups')}</Label>
{isLoadingGroups ? (
<Skeleton className='h-10 w-full' />
) : (
<MultiSelect
options={groupOptions}
selected={groups}
onChange={setGroups}
placeholder={t('Select groups (leave empty to keep current)')}
/>
)}
<p className='text-muted-foreground text-xs'>
{t('User groups that can access channels with this tag')}
</p>
</div>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
</>
)}
) : (
<>
<div className='space-y-4 py-4'>
<Alert>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
{t(
'All edits are overwrite operations. Leave fields empty to keep current values unchanged.'
)}
</AlertDescription>
</Alert>
{/* Tag Name */}
<div className='space-y-2'>
<Label htmlFor='new-tag'>{t('Tag Name')}</Label>
<Input
id='new-tag'
placeholder={t(
'Enter new tag name (leave empty to disband tag)'
)}
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
disabled={isSaving}
/>
<p className='text-muted-foreground text-xs'>
{t('Leave empty to disband the tag')}
</p>
</div>
{/* Models */}
<div className='space-y-2'>
<Label htmlFor='models'>{t('Models')}</Label>
<Textarea
id='models'
placeholder={t(
'Comma-separated model names (leave empty to keep current)'
)}
value={models}
onChange={(e) => setModels(e.target.value)}
disabled={isSaving}
rows={3}
/>
<p className='text-muted-foreground text-xs'>
{t(
'Current models for the longest channel in this tag. May not include all models from all channels.'
)}
</p>
</div>
{/* Model Mapping */}
<div className='space-y-2'>
<Label htmlFor='model-mapping'>{t('Model Mapping')}</Label>
<ModelMappingEditor
value={modelMapping}
onChange={setModelMapping}
disabled={isSaving}
/>
</div>
{/* Groups */}
<div className='space-y-2'>
<Label htmlFor='groups'>{t('Groups')}</Label>
{isLoadingGroups ? (
<Skeleton className='h-10 w-full' />
) : (
<MultiSelect
options={groupOptions}
selected={groups}
onChange={setGroups}
placeholder={t(
'Select groups (leave empty to keep current)'
)}
/>
)}
<p className='text-muted-foreground text-xs'>
{t('User groups that can access channels with this tag')}
</p>
</div>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={handleClose}
disabled={isSaving}
>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isSaving ? t('Saving...') : t('Save Changes')}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
)
}
@@ -21,11 +21,17 @@ import { Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
interface UpstreamUpdateDialogProps {
@@ -114,15 +120,157 @@ export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
return (
<>
<Dialog
open={props.open}
onOpenChange={(v) => !v && props.onCancel()}
title={t('Upstream Model Updates')}
contentClassName='sm:max-w-lg'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Dialog open={props.open} onOpenChange={(v) => !v && props.onCancel()}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Upstream Model Updates')}</DialogTitle>
</DialogHeader>
<p className='text-muted-foreground text-sm'>
{t(
'Select models to process. Unselected "add" models will be ignored.'
)}
</p>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as 'add' | 'remove')}
>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='add' className='gap-1'>
{t('Add Models')}
<StatusBadge
variant='neutral'
className='ml-1'
copyable={false}
>
{selectedAdd.size}/{props.addModels.length}
</StatusBadge>
</TabsTrigger>
<TabsTrigger value='remove' className='gap-1'>
{t('Remove Models')}
<StatusBadge
variant='neutral'
className='ml-1'
copyable={false}
>
{selectedRemove.size}/{props.removeModels.length}
</StatusBadge>
</TabsTrigger>
</TabsList>
<TabsContent value='add' className='space-y-3'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder={t('Search models...')}
className='pl-8'
value={searchAdd}
onChange={(e) => setSearchAdd(e.target.value)}
/>
</div>
{filteredAdd.length > 0 && (
<div className='flex items-center gap-2'>
<Checkbox
checked={filteredAdd.every((m) => selectedAdd.has(m))}
onCheckedChange={() =>
toggleAllVisible(filteredAdd, selectedAdd, setSelectedAdd)
}
/>
<span className='text-muted-foreground text-xs'>
{t('Select All Visible')}
</span>
</div>
)}
<ScrollArea className='h-[280px] rounded-md border p-2'>
{filteredAdd.length > 0 ? (
<div className='space-y-1'>
{filteredAdd.map((model) => (
<label
key={model}
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
>
<Checkbox
checked={selectedAdd.has(model)}
onCheckedChange={() =>
toggleModel(model, selectedAdd, setSelectedAdd)
}
/>
<span className='truncate text-sm'>{model}</span>
</label>
))}
</div>
) : (
<p className='text-muted-foreground py-8 text-center text-sm'>
{props.addModels.length === 0
? t('No models to add')
: t('No matching results')}
</p>
)}
</ScrollArea>
</TabsContent>
<TabsContent value='remove' className='space-y-3'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder={t('Search models...')}
className='pl-8'
value={searchRemove}
onChange={(e) => setSearchRemove(e.target.value)}
/>
</div>
{filteredRemove.length > 0 && (
<div className='flex items-center gap-2'>
<Checkbox
checked={filteredRemove.every((m) => selectedRemove.has(m))}
onCheckedChange={() =>
toggleAllVisible(
filteredRemove,
selectedRemove,
setSelectedRemove
)
}
/>
<span className='text-muted-foreground text-xs'>
{t('Select All Visible')}
</span>
</div>
)}
<ScrollArea className='h-[280px] rounded-md border p-2'>
{filteredRemove.length > 0 ? (
<div className='space-y-1'>
{filteredRemove.map((model) => (
<label
key={model}
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
>
<Checkbox
checked={selectedRemove.has(model)}
onCheckedChange={() =>
toggleModel(
model,
selectedRemove,
setSelectedRemove
)
}
/>
<span className='truncate text-sm'>{model}</span>
</label>
))}
</div>
) : (
<p className='text-muted-foreground py-8 text-center text-sm'>
{props.removeModels.length === 0
? t('No models to remove')
: t('No matching results')}
</p>
)}
</ScrollArea>
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant='outline' onClick={props.onCancel}>
{t('Cancel')}
</Button>
@@ -136,139 +284,8 @@ export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
>
{t('Confirm')}
</Button>
</>
}
>
<p className='text-muted-foreground text-sm'>
{t(
'Select models to process. Unselected "add" models will be ignored.'
)}
</p>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as 'add' | 'remove')}
>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='add' className='gap-1'>
{t('Add Models')}
<StatusBadge variant='neutral' className='ml-1' copyable={false}>
{selectedAdd.size}/{props.addModels.length}
</StatusBadge>
</TabsTrigger>
<TabsTrigger value='remove' className='gap-1'>
{t('Remove Models')}
<StatusBadge variant='neutral' className='ml-1' copyable={false}>
{selectedRemove.size}/{props.removeModels.length}
</StatusBadge>
</TabsTrigger>
</TabsList>
<TabsContent value='add' className='space-y-3'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder={t('Search models...')}
className='pl-8'
value={searchAdd}
onChange={(e) => setSearchAdd(e.target.value)}
/>
</div>
{filteredAdd.length > 0 && (
<div className='flex items-center gap-2'>
<Checkbox
checked={filteredAdd.every((m) => selectedAdd.has(m))}
onCheckedChange={() =>
toggleAllVisible(filteredAdd, selectedAdd, setSelectedAdd)
}
/>
<span className='text-muted-foreground text-xs'>
{t('Select All Visible')}
</span>
</div>
)}
<ScrollArea className='h-[280px] rounded-md border p-2'>
{filteredAdd.length > 0 ? (
<div className='space-y-1'>
{filteredAdd.map((model) => (
<label
key={model}
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
>
<Checkbox
checked={selectedAdd.has(model)}
onCheckedChange={() =>
toggleModel(model, selectedAdd, setSelectedAdd)
}
/>
<span className='truncate text-sm'>{model}</span>
</label>
))}
</div>
) : (
<p className='text-muted-foreground py-8 text-center text-sm'>
{props.addModels.length === 0
? t('No models to add')
: t('No matching results')}
</p>
)}
</ScrollArea>
</TabsContent>
<TabsContent value='remove' className='space-y-3'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder={t('Search models...')}
className='pl-8'
value={searchRemove}
onChange={(e) => setSearchRemove(e.target.value)}
/>
</div>
{filteredRemove.length > 0 && (
<div className='flex items-center gap-2'>
<Checkbox
checked={filteredRemove.every((m) => selectedRemove.has(m))}
onCheckedChange={() =>
toggleAllVisible(
filteredRemove,
selectedRemove,
setSelectedRemove
)
}
/>
<span className='text-muted-foreground text-xs'>
{t('Select All Visible')}
</span>
</div>
)}
<ScrollArea className='h-[280px] rounded-md border p-2'>
{filteredRemove.length > 0 ? (
<div className='space-y-1'>
{filteredRemove.map((model) => (
<label
key={model}
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
>
<Checkbox
checked={selectedRemove.has(model)}
onCheckedChange={() =>
toggleModel(model, selectedRemove, setSelectedRemove)
}
/>
<span className='truncate text-sm'>{model}</span>
</label>
))}
</div>
) : (
<p className='text-muted-foreground py-8 text-center text-sm'>
{props.removeModels.length === 0
? t('No models to remove')
: t('No matching results')}
</p>
)}
</ScrollArea>
</TabsContent>
</Tabs>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
@@ -21,6 +21,15 @@ import { Save, Settings2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { TimeGranularity } from '@/lib/time'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import {
Select,
@@ -30,7 +39,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Dialog } from '@/components/dialog'
import {
CONSUMPTION_DISTRIBUTION_CHART_OPTIONS,
MODEL_ANALYTICS_CHART_OPTIONS,
@@ -66,158 +74,165 @@ export function ModelsChartPreferences(props: ModelsChartPreferencesProps) {
}
return (
<Dialog
open={open}
onOpenChange={handleOpenChange}
trigger={
<Button variant='outline' size='sm'>
<Settings2 className='mr-2 h-4 w-4' />
{t('Preferences')}
</Button>
}
title={t('Model Analytics Defaults')}
description={t('Set default ranges and charts for model analytics.')}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='grid gap-3'
footer={
<Button onClick={handleSave} type='button'>
<Save className='mr-2 h-4 w-4' />
{t('Save Preferences')}
</Button>
}
>
<div className='grid gap-1.5'>
<Label htmlFor='default-time-range'>{t('Default range')}</Label>
<Select
items={[
...TIME_RANGE_PRESETS.map((option) => ({
value: String(option.days),
label: t(option.label),
})),
]}
value={String(draft.defaultTimeRangeDays)}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
defaultTimeRangeDays: Number(value),
}))
}
>
<SelectTrigger id='default-time-range'>
<SelectValue placeholder={t('Select default range')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{TIME_RANGE_PRESETS.map((option) => (
<SelectItem key={option.days} value={String(option.days)}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='grid gap-1.5'>
<Label htmlFor='default-time-granularity'>
{t('Default time granularity')}
</Label>
<Select
items={[
...TIME_GRANULARITY_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
})),
]}
value={draft.defaultTimeGranularity}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
defaultTimeGranularity: value as TimeGranularity,
}))
}
>
<SelectTrigger id='default-time-granularity'>
<SelectValue placeholder={t('Select time granularity')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{TIME_GRANULARITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='grid gap-1.5'>
<Label htmlFor='consumption-distribution-chart'>
{t('Default consumption chart')}
</Label>
<Select
items={[
...CONSUMPTION_DISTRIBUTION_CHART_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey),
})),
]}
value={draft.consumptionDistributionChart}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
consumptionDistributionChart:
value as ConsumptionDistributionChartType,
}))
}
>
<SelectTrigger id='consumption-distribution-chart'>
<SelectValue placeholder={t('Select default chart')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{CONSUMPTION_DISTRIBUTION_CHART_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.labelKey)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='grid gap-1.5'>
<Label htmlFor='model-analytics-chart'>
{t('Default model call chart')}
</Label>
<Select
items={[
...MODEL_ANALYTICS_CHART_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey),
})),
]}
value={draft.modelAnalyticsChart}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
modelAnalyticsChart: value as ModelAnalyticsChartTab,
}))
}
>
<SelectTrigger id='model-analytics-chart'>
<SelectValue placeholder={t('Select default chart')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{MODEL_ANALYTICS_CHART_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.labelKey)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger render={<Button variant='outline' size='sm' />}>
<Settings2 className='mr-2 h-4 w-4' />
{t('Preferences')}
</DialogTrigger>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Dashboard Preferences')}</DialogTitle>
<DialogDescription>
{t(
'Choose the default charts, range, and time granularity for model analytics.'
)}
</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-2'>
<div className='grid gap-2'>
<Label htmlFor='default-time-range'>{t('Default range')}</Label>
<Select
items={[
...TIME_RANGE_PRESETS.map((option) => ({
value: String(option.days),
label: t(option.label),
})),
]}
value={String(draft.defaultTimeRangeDays)}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
defaultTimeRangeDays: Number(value),
}))
}
>
<SelectTrigger id='default-time-range'>
<SelectValue placeholder={t('Select default range')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{TIME_RANGE_PRESETS.map((option) => (
<SelectItem key={option.days} value={String(option.days)}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label htmlFor='default-time-granularity'>
{t('Default time granularity')}
</Label>
<Select
items={[
...TIME_GRANULARITY_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
})),
]}
value={draft.defaultTimeGranularity}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
defaultTimeGranularity: value as TimeGranularity,
}))
}
>
<SelectTrigger id='default-time-granularity'>
<SelectValue placeholder={t('Select time granularity')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{TIME_GRANULARITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label htmlFor='consumption-distribution-chart'>
{t('Default consumption chart')}
</Label>
<Select
items={[
...CONSUMPTION_DISTRIBUTION_CHART_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey),
})),
]}
value={draft.consumptionDistributionChart}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
consumptionDistributionChart:
value as ConsumptionDistributionChartType,
}))
}
>
<SelectTrigger id='consumption-distribution-chart'>
<SelectValue placeholder={t('Select default chart')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{CONSUMPTION_DISTRIBUTION_CHART_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.labelKey)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label htmlFor='model-analytics-chart'>
{t('Default model call chart')}
</Label>
<Select
items={[
...MODEL_ANALYTICS_CHART_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey),
})),
]}
value={draft.modelAnalyticsChart}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
modelAnalyticsChart: value as ModelAnalyticsChartTab,
}))
}
>
<SelectTrigger id='model-analytics-chart'>
<SelectValue placeholder={t('Select default chart')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{MODEL_ANALYTICS_CHART_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.labelKey)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button onClick={handleSave} type='button'>
<Save className='mr-2 h-4 w-4' />
{t('Save Preferences')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -23,6 +23,15 @@ import { useAuthStore } from '@/stores/auth-store'
import { getRollingDateRange, type TimeGranularity } from '@/lib/time'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
@@ -35,7 +44,6 @@ import {
SelectValue,
} from '@/components/ui/select'
import { DateTimePicker } from '@/components/datetime-picker'
import { Dialog } from '@/components/dialog'
import {
TIME_GRANULARITY_OPTIONS,
TIME_RANGE_PRESETS,
@@ -136,22 +144,129 @@ export function ModelsFilter(props: ModelsFilterProps) {
}
return (
<Dialog
open={open}
onOpenChange={handleOpenChange}
trigger={
<Button variant='outline' size='sm'>
<Filter className='mr-2 h-4 w-4' />
{t('Filter')}
</Button>
}
title={t('Model Analytics Filters')}
description={t('Filter the model analytics view by time range and user.')}
contentClassName='max-sm:h-dvh max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-lg'
contentHeight='min(48vh, 460px)'
footerClassName='grid grid-cols-2 gap-2 sm:flex'
footer={
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger render={<Button variant='outline' size='sm' />}>
<Filter className='mr-2 h-4 w-4' />
{t('Filter')}
</DialogTrigger>
<DialogContent className='flex max-h-[calc(100dvh-2rem)] flex-col max-sm:h-dvh max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Filter Dashboard Models')}</DialogTitle>
<DialogDescription>
{t(
'Set filters to customize your dashboard statistics and charts.'
)}
</DialogDescription>
</DialogHeader>
<ScrollArea className='flex-1 pr-3 sm:pr-4'>
<div className='grid gap-3 py-3 sm:gap-4 sm: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='grid grid-cols-2 gap-2 sm:flex'>
{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-3 sm:gap-4'>
<div className='grid gap-2'>
<Label htmlFor='start_timestamp'>{t('Start Time')}</Label>
<DateTimePicker
value={filters.start_timestamp}
onChange={(date) =>
handleChange('start_timestamp', date || undefined)
}
placeholder={t('Select start time')}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='end_timestamp'>{t('End Time')}</Label>
<DateTimePicker
value={filters.end_timestamp}
onChange={(date) =>
handleChange('end_timestamp', date || undefined)
}
placeholder={t('Select end time')}
/>
</div>
</div>
<SectionDivider label={t('Chart Settings')} />
<div className='grid gap-2'>
<Label htmlFor='time_granularity'>{t('Time Granularity')}</Label>
<Select
items={[
...TIME_GRANULARITY_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
})),
]}
value={filters.time_granularity}
onValueChange={(value) =>
handleChange('time_granularity', value as TimeGranularity)
}
>
<SelectTrigger>
<SelectValue placeholder={t('Select time granularity')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{TIME_GRANULARITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
{/* Admin-only fields */}
{isAdmin && (
<>
<SectionDivider label={t('Admin Only')} />
<div className='grid gap-2'>
<Label htmlFor='username'>{t('Username')}</Label>
<Input
id='username'
placeholder={t('Filter by username')}
value={filters.username}
onChange={(e) => handleChange('username', e.target.value)}
/>
</div>
</>
)}
</div>
</ScrollArea>
<DialogFooter className='grid grid-cols-2 gap-2 sm:flex'>
<Button onClick={handleReset} variant='outline' type='button'>
<RotateCcw className='mr-2 h-4 w-4' />
{t('Reset')}
@@ -160,113 +275,8 @@ export function ModelsFilter(props: ModelsFilterProps) {
<Search className='mr-2 h-4 w-4' />
{t('Apply Filters')}
</Button>
</>
}
>
<ScrollArea className='h-full pr-3 sm:pr-4'>
<div className='grid gap-2.5 py-2'>
{/* 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='grid grid-cols-2 gap-2 sm:flex'>
{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-2.5'>
<div className='grid gap-2'>
<Label htmlFor='start_timestamp'>{t('Start Time')}</Label>
<DateTimePicker
value={filters.start_timestamp}
onChange={(date) =>
handleChange('start_timestamp', date || undefined)
}
placeholder={t('Select start time')}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='end_timestamp'>{t('End Time')}</Label>
<DateTimePicker
value={filters.end_timestamp}
onChange={(date) =>
handleChange('end_timestamp', date || undefined)
}
placeholder={t('Select end time')}
/>
</div>
</div>
<SectionDivider label={t('Chart Settings')} />
<div className='grid gap-2'>
<Label htmlFor='time_granularity'>{t('Time Granularity')}</Label>
<Select
items={[
...TIME_GRANULARITY_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
})),
]}
value={filters.time_granularity}
onValueChange={(value) =>
handleChange('time_granularity', value as TimeGranularity)
}
>
<SelectTrigger>
<SelectValue placeholder={t('Select time granularity')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{TIME_GRANULARITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
{/* Admin-only fields */}
{isAdmin && (
<>
<SectionDivider label={t('Admin Only')} />
<div className='grid gap-2'>
<Label htmlFor='username'>{t('Username')}</Label>
<Input
id='username'
placeholder={t('Filter by username')}
value={filters.username}
onChange={(e) => handleChange('username', e.target.value)}
/>
</div>
</>
)}
</div>
</ScrollArea>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -18,9 +18,15 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import { formatDateTimeObject } from '@/lib/time'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Markdown } from '@/components/ui/markdown'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Dialog } from '@/components/dialog'
interface AnnouncementDetailModalProps {
open: boolean
@@ -41,39 +47,38 @@ export function AnnouncementDetailModal({
}: AnnouncementDetailModalProps) {
const { t } = useTranslation()
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Announcement Details')}
description={
announcement?.publishDate
? `${t('Published:')} ${formatDateTimeObject(new Date(announcement.publishDate))}`
: undefined
}
contentClassName='sm:max-w-lg'
contentHeight='auto'
bodyClassName='space-y-4'
>
<ScrollArea className='max-h-[min(58vh,520px)] pr-4'>
<div className='space-y-4'>
{announcement?.content && (
<div>
<h4 className='mb-2 font-medium'>{t('Content')}</h4>
<Markdown>{announcement.content}</Markdown>
</div>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Announcement Details')}</DialogTitle>
{announcement?.publishDate && (
<DialogDescription>
{t('Published:')}{' '}
{formatDateTimeObject(new Date(announcement.publishDate))}
</DialogDescription>
)}
{announcement?.extra && (
<div>
<h4 className='mb-2 font-medium'>
{t('Additional Information')}
</h4>
<Markdown className='text-muted-foreground'>
{announcement.extra}
</Markdown>
</div>
)}
</div>
</ScrollArea>
</DialogHeader>
<ScrollArea className='max-h-[60vh] pr-4'>
<div className='space-y-4'>
{announcement?.content && (
<div>
<h4 className='mb-2 font-medium'>{t('Content')}</h4>
<Markdown>{announcement.content}</Markdown>
</div>
)}
{announcement?.extra && (
<div>
<h4 className='mb-2 font-medium'>
{t('Additional Information')}
</h4>
<Markdown className='text-muted-foreground'>
{announcement.extra}
</Markdown>
</div>
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
)
}
@@ -23,9 +23,15 @@ import { toast } from 'sonner'
import { getUserModels } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { ComboboxInput } from '@/components/ui/combobox-input'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Dialog } from '@/components/dialog'
const APP_CONFIGS = {
claude: {
@@ -145,78 +151,76 @@ export function CCSwitchDialog(props: Props) {
}
return (
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={t('Import to CC Switch')}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Import to CC Switch')}</DialogTitle>
</DialogHeader>
<div className='space-y-4'>
<div className='space-y-2'>
<Label>{t('Application')}</Label>
<RadioGroup
value={app}
onValueChange={handleAppChange}
className='flex gap-4'
>
{(
Object.entries(APP_CONFIGS) as [
AppType,
(typeof APP_CONFIGS)[AppType],
][]
).map(([key, cfg]) => (
<div key={key} className='flex items-center gap-2'>
<RadioGroupItem value={key} id={`app-${key}`} />
<Label htmlFor={`app-${key}`} className='cursor-pointer'>
{cfg.label}
</Label>
</div>
))}
</RadioGroup>
</div>
<div className='space-y-2'>
<Label>{t('Name')}</Label>
<ComboboxInput
options={[]}
value={name}
onValueChange={setName}
placeholder={currentConfig.defaultName}
emptyText=''
allowCustomValue={true}
/>
</div>
{currentConfig.modelFields.map((field) => (
<div key={field.key} className='space-y-2'>
<Label>
{t(field.labelKey)}
{field.required && (
<span className='text-destructive ml-0.5'>*</span>
)}
</Label>
<ComboboxInput
options={modelOptions}
value={models[field.key] || ''}
onValueChange={(v) =>
setModels((prev) => ({ ...prev, [field.key]: v }))
}
placeholder={t('Select or enter model name')}
emptyText={t('No models found')}
/>
</div>
))}
</div>
<DialogFooter>
<Button variant='outline' onClick={() => props.onOpenChange(false)}>
{t('Cancel')}
</Button>
<Button onClick={handleSubmit}>{t('Open CC Switch')}</Button>
</>
}
>
<div className='space-y-4'>
<div className='space-y-2'>
<Label>{t('Application')}</Label>
<RadioGroup
value={app}
onValueChange={handleAppChange}
className='flex gap-4'
>
{(
Object.entries(APP_CONFIGS) as [
AppType,
(typeof APP_CONFIGS)[AppType],
][]
).map(([key, cfg]) => (
<div key={key} className='flex items-center gap-2'>
<RadioGroupItem value={key} id={`app-${key}`} />
<Label htmlFor={`app-${key}`} className='cursor-pointer'>
{cfg.label}
</Label>
</div>
))}
</RadioGroup>
</div>
<div className='space-y-2'>
<Label>{t('Name')}</Label>
<ComboboxInput
options={[]}
value={name}
onValueChange={setName}
placeholder={currentConfig.defaultName}
emptyText=''
allowCustomValue={true}
/>
</div>
{currentConfig.modelFields.map((field) => (
<div key={field.key} className='space-y-2'>
<Label>
{t(field.labelKey)}
{field.required && (
<span className='text-destructive ml-0.5'>*</span>
)}
</Label>
<ComboboxInput
options={modelOptions}
value={models[field.key] || ''}
onValueChange={(v) =>
setModels((prev) => ({ ...prev, [field.key]: v }))
}
placeholder={t('Select or enter model name')}
emptyText={t('No models found')}
/>
</div>
))}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -24,13 +24,20 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { copyToClipboard } from '@/lib/copy-to-clipboard'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import {
handleBatchEnableModels,
handleBatchDisableModels,
@@ -180,17 +187,19 @@ export function DataTableBulkActions<TData>({
</BulkActionsToolbar>
{/* Delete Confirmation Dialog */}
<Dialog
open={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
title={t('Delete Models?')}
description={t(
'Are you sure you want to delete {{count}} model(s)? This action cannot be undone.',
{ count: selectedIds.length }
)}
contentHeight='auto'
footer={
<>
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Delete Models?')}</DialogTitle>
<DialogDescription>
{t(
'Are you sure you want to delete {{count}} model(s)? This action cannot be undone.',
{ count: selectedIds.length }
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant='outline'
onClick={() => setShowDeleteConfirm(false)}
@@ -200,10 +209,8 @@ export function DataTableBulkActions<TData>({
<Button variant='destructive' onClick={handleDeleteAll}>
{t('Delete')}
</Button>
</>
}
>
{' '}
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
@@ -17,8 +17,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Dialog } from '@/components/dialog'
type DescriptionDialogProps = {
open: boolean
@@ -35,22 +41,21 @@ export function DescriptionDialog({
}: DescriptionDialogProps) {
const { t } = useTranslation()
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={modelName}
description={t('Model Description')}
contentClassName='max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
>
<ScrollArea className='max-h-96'>
<div className='space-y-2 pr-4'>
<p className='text-foreground text-sm leading-relaxed break-words whitespace-pre-wrap'>
{description}
</p>
</div>
</ScrollArea>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-w-2xl'>
<DialogHeader>
<DialogTitle>{modelName}</DialogTitle>
<DialogDescription>{t('Model Description')}</DialogDescription>
</DialogHeader>
<ScrollArea className='max-h-96'>
<div className='space-y-2 pr-4'>
<p className='text-foreground text-sm leading-relaxed break-words whitespace-pre-wrap'>
{description}
</p>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
)
}
@@ -22,9 +22,15 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import { Dialog } from '@/components/dialog'
import { estimatePrice, extendDeployment, getDeployment } from '../../api'
import { deploymentsQueryKeys } from '../../lib'
@@ -158,16 +164,62 @@ export function ExtendDeploymentDialog({
}
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Extend deployment')}
contentClassName='sm:max-w-lg'
footerClassName='mt-4'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Extend deployment')}</DialogTitle>
</DialogHeader>
{isLoadingDetails ? (
<div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : (
<div className='space-y-4'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}:{' '}
<span className='font-mono'>{deploymentId}</span>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>{t('Duration (hours)')}</div>
<Input
type='number'
min={1}
value={hours}
onChange={(e) => setHours(toInt(e.target.value, 1))}
/>
<div className='text-muted-foreground text-xs'>
{t('This will extend the deployment by the specified hours.')}
</div>
</div>
<Separator />
<div className='space-y-1'>
<div className='text-sm font-medium'>{t('Estimated cost')}</div>
<div className='text-muted-foreground text-sm'>
{isLoadingPrice || isFetchingPrice ? (
<span className='inline-flex items-center gap-2'>
<Loader2 className='h-4 w-4 animate-spin' />
{t('Calculating...')}
</span>
) : priceParams ? (
priceSummary || t('Not available')
) : (
t('Not available')
)}
</div>
{!priceParams ? (
<div className='text-muted-foreground text-xs'>
{t('Unable to estimate price for this deployment.')}
</div>
) : null}
</div>
</div>
)}
<DialogFooter className='mt-4'>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{t('Cancel')}
</Button>
@@ -177,57 +229,8 @@ export function ExtendDeploymentDialog({
) : null}
{t('Extend')}
</Button>
</>
}
>
{isLoadingDetails ? (
<div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : (
<div className='space-y-4'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}:{' '}
<span className='font-mono'>{deploymentId}</span>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>{t('Duration (hours)')}</div>
<Input
type='number'
min={1}
value={hours}
onChange={(e) => setHours(toInt(e.target.value, 1))}
/>
<div className='text-muted-foreground text-xs'>
{t('This will extend the deployment by the specified hours.')}
</div>
</div>
<Separator />
<div className='space-y-1'>
<div className='text-sm font-medium'>{t('Estimated cost')}</div>
<div className='text-muted-foreground text-sm'>
{isLoadingPrice || isFetchingPrice ? (
<span className='inline-flex items-center gap-2'>
<Loader2 className='h-4 w-4 animate-spin' />
{t('Calculating...')}
</span>
) : priceParams ? (
priceSummary || t('Not available')
) : (
t('Not available')
)}
</div>
{!priceParams ? (
<div className='text-muted-foreground text-xs'>
{t('Unable to estimate price for this deployment.')}
</div>
) : null}
</div>
</div>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -22,6 +22,13 @@ import { ChevronLeft, ChevronRight, Loader2, Plus, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useIsMobile } from '@/hooks/use-mobile'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Empty,
EmptyDescription,
@@ -30,7 +37,6 @@ import {
EmptyTitle,
} from '@/components/ui/empty'
import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { getMissingModels } from '../../api'
import { DEFAULT_PAGE_SIZE } from '../../constants'
@@ -109,130 +115,133 @@ export function MissingModelsDialog({
const showPagination = totalItems > pageSize
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Missing Models')}
description={t(
'Models that are being used but not configured in the system'
)}
contentClassName='flex max-h-[85vh] max-w-2xl flex-col gap-3 p-4'
headerClassName='flex-shrink-0 text-start'
contentHeight='min(74vh, 760px)'
bodyClassName='space-y-4'
initialFocus={!isMobile}
>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='h-8 w-8 animate-spin' />
</div>
) : missingModels.length === 0 ? (
<div className='text-muted-foreground py-12 text-center'>
<p>{t('No missing models found.')}</p>
<p className='text-sm'>
{t('All models in use are properly configured.')}
</p>
</div>
) : (
<div className='flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto'>
<div className='flex flex-shrink-0 items-center justify-between gap-3'>
<div className='text-muted-foreground text-sm whitespace-nowrap'>
{t('Showing')} {displayStart}-{displayEnd} {t('of')} {totalItems}
</div>
<div className='relative w-48'>
<Search className='text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
value={searchTerm}
onChange={(event) => {
setSearchTerm(event.target.value)
setCurrentPage(1)
}}
placeholder={t('Search models...')}
className='pl-9'
aria-label={t('Search missing models')}
/>
</div>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className='flex max-h-[85vh] max-w-2xl flex-col gap-3 p-4'
initialFocus={!isMobile}
>
<DialogHeader className='flex-shrink-0 text-start'>
<DialogTitle>{t('Missing Models')}</DialogTitle>
<DialogDescription>
{t('Models that are being used but not configured in the system')}
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='h-8 w-8 animate-spin' />
</div>
{filteredModels.length === 0 ? (
<Empty className='border'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Search className='h-5 w-5' />
</EmptyMedia>
<EmptyTitle>{t('No matches found')}</EmptyTitle>
<EmptyDescription>
{t('Try adjusting your search to locate a missing model.')}
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className='flex-shrink-0 rounded-lg border'>
<div className='divide-y'>
{paginatedModels.map((modelName) => (
<div
key={modelName}
className='flex items-center justify-between gap-3 p-3'
>
<div className='min-w-0 flex-1'>
<StatusBadge
label={modelName}
variant='neutral'
copyText={modelName}
/>
</div>
<Button
size='sm'
className='flex-shrink-0 gap-1'
onClick={() => handleConfigureModel(modelName)}
>
<Plus className='h-4 w-4' />
Configure
</Button>
</div>
))}
) : missingModels.length === 0 ? (
<div className='text-muted-foreground py-12 text-center'>
<p>{t('No missing models found.')}</p>
<p className='text-sm'>
{t('All models in use are properly configured.')}
</p>
</div>
) : (
<div className='flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto'>
<div className='flex flex-shrink-0 items-center justify-between gap-3'>
<div className='text-muted-foreground text-sm whitespace-nowrap'>
{t('Showing')} {displayStart}-{displayEnd} {t('of')}{' '}
{totalItems}
</div>
<div className='bg-muted/40 flex items-center justify-between border-t px-3 py-2 text-sm'>
<div className='text-muted-foreground text-sm'>
{t('Page {{current}} of {{total}}', {
current: currentPage,
total: totalPages,
})}
</div>
{showPagination && (
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='icon'
className='h-8 w-8'
onClick={() =>
setCurrentPage((prev) => Math.max(1, prev - 1))
}
disabled={currentPage === 1}
aria-label={t('Previous page')}
>
<ChevronLeft className='h-4 w-4' />
</Button>
<Button
variant='outline'
size='icon'
className='h-8 w-8'
onClick={() =>
setCurrentPage((prev) => Math.min(totalPages, prev + 1))
}
disabled={currentPage === totalPages}
aria-label={t('Next page')}
>
<ChevronRight className='h-4 w-4' />
</Button>
</div>
)}
<div className='relative w-48'>
<Search className='text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
value={searchTerm}
onChange={(event) => {
setSearchTerm(event.target.value)
setCurrentPage(1)
}}
placeholder={t('Search models...')}
className='pl-9'
aria-label={t('Search missing models')}
/>
</div>
</div>
)}
</div>
)}
{filteredModels.length === 0 ? (
<Empty className='border'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Search className='h-5 w-5' />
</EmptyMedia>
<EmptyTitle>{t('No matches found')}</EmptyTitle>
<EmptyDescription>
{t('Try adjusting your search to locate a missing model.')}
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className='flex-shrink-0 rounded-lg border'>
<div className='divide-y'>
{paginatedModels.map((modelName) => (
<div
key={modelName}
className='flex items-center justify-between gap-3 p-3'
>
<div className='min-w-0 flex-1'>
<StatusBadge
label={modelName}
variant='neutral'
copyText={modelName}
/>
</div>
<Button
size='sm'
className='flex-shrink-0 gap-1'
onClick={() => handleConfigureModel(modelName)}
>
<Plus className='h-4 w-4' />
Configure
</Button>
</div>
))}
</div>
<div className='bg-muted/40 flex items-center justify-between border-t px-3 py-2 text-sm'>
<div className='text-muted-foreground text-sm'>
{t('Page {{current}} of {{total}}', {
current: currentPage,
total: totalPages,
})}
</div>
{showPagination && (
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='icon'
className='h-8 w-8'
onClick={() =>
setCurrentPage((prev) => Math.max(1, prev - 1))
}
disabled={currentPage === 1}
aria-label={t('Previous page')}
>
<ChevronLeft className='h-4 w-4' />
</Button>
<Button
variant='outline'
size='icon'
className='h-8 w-8'
onClick={() =>
setCurrentPage((prev) =>
Math.min(totalPages, prev + 1)
)
}
disabled={currentPage === totalPages}
aria-label={t('Next page')}
>
<ChevronRight className='h-4 w-4' />
</Button>
</div>
)}
</div>
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
)
}
@@ -25,6 +25,7 @@ import {
Plus,
RefreshCcw,
Trash2,
X,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -39,6 +40,14 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Empty,
EmptyDescription,
@@ -55,7 +64,6 @@ import {
TableRow,
} from '@/components/ui/table'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import { deletePrefillGroup, getPrefillGroups } from '../../api'
@@ -164,233 +172,186 @@ export function PrefillGroupManagementDialog({
return (
<>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
<Layers3 className='text-foreground/80 h-5 w-5' />
{t('Prefill Group Management')}
</>
}
description={t(
'Create reusable bundles of models, tags, endpoints, and user groups to speed up configuration elsewhere in the console.'
)}
contentClassName={cn(
'w-[calc(100vw-2rem)] sm:max-w-[52rem]',
isMobile && 'max-w-none rounded-none'
)}
titleClassName='flex flex-wrap items-center gap-2 text-lg'
descriptionClassName='text-sm leading-relaxed'
contentHeight='auto'
bodyClassName={cn(
'space-y-3',
isMobile && 'pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]'
)}
>
<div className='bg-muted/30 flex flex-wrap items-center justify-between gap-3 rounded-md border p-2 text-sm'>
<div className='flex flex-wrap items-center gap-2'>
<Button size='sm' onClick={onCreateGroup}>
<Plus className='mr-2 h-4 w-4' />
{t('New Group')}
</Button>
<Button
size='sm'
variant='ghost'
onClick={() => refetchGroups()}
disabled={isFetching}
>
{isFetching ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCcw className='mr-2 h-4 w-4' />
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className='prefill-dialog-content !top-4 !flex !-translate-y-0 !flex-col !gap-0 !border-none !bg-transparent !p-0 !shadow-none sm:!top-1/2 sm:!-translate-y-1/2'
style={{ maxWidth: 'min(100vw, 64rem)' }}
>
<div
className={cn(
'prefill-dialog-panel border-border/70 bg-background flex max-h-[calc(100dvh-1.5rem)] flex-col overflow-hidden border shadow-2xl',
isMobile ? 'rounded-none' : 'rounded-2xl'
)}
>
<div
className={cn(
'relative flex flex-col gap-3 border-b px-4 py-4 sm:px-6 sm:py-5',
isMobile && 'pt-[calc(env(safe-area-inset-top,0px)+1rem)]'
)}
{t('Refresh')}
</Button>
</div>
<StatusBadge
label={`${groups.length} group${groups.length === 1 ? '' : 's'}`}
variant='neutral'
copyable={false}
/>
</div>
<div className='flex flex-col gap-3'>
{error && (
<Alert variant='destructive'>
<AlertTitle>{t('Unable to load groups')}</AlertTitle>
<AlertDescription>
{(error as Error).message ||
'Please retry or refresh the page.'}
</AlertDescription>
</Alert>
)}
{isLoading ? (
<div className='flex flex-col items-center justify-center gap-2 py-12 text-center'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
<p className='text-muted-foreground text-sm'>
{t('Fetching prefill groups...')}
</p>
</div>
) : normalizedGroups.length === 0 ? (
<Empty className='border border-dashed py-10'>
<EmptyMedia variant='icon'>
<Layers3 className='h-6 w-6' />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>{t('No prefill groups yet')}</EmptyTitle>
<EmptyDescription>
>
<DialogHeader className='max-w-3xl gap-3 pr-12 text-start sm:pr-0'>
<DialogTitle className='flex flex-wrap items-center gap-2 text-xl'>
<Layers3 className='text-foreground/80 h-5 w-5' />
{t('Prefill Group Management')}
</DialogTitle>
<DialogDescription className='text-base leading-relaxed sm:text-sm'>
{t(
'Create your first group to reuse model, tag, or endpoint selections anywhere in the dashboard.'
'Create reusable bundles of models, tags, endpoints, and user groups to speed up configuration elsewhere in the console.'
)}
</EmptyDescription>
</EmptyHeader>
<EmptyDescription>
{t(
'Prefill groups help you keep complex configurations in sync.'
)}
</EmptyDescription>
</Empty>
) : isMobile ? (
<div className='space-y-3'>
{normalizedGroups.map(({ group, meta, parsedItems }) => (
<Card key={group.id} className='border-border/60'>
<CardHeader className='flex flex-row items-start justify-between gap-4'>
<div className='space-y-2'>
<CardTitle className='flex flex-wrap items-center gap-2'>
{group.name}
<StatusBadge
variant={meta.badge}
size='sm'
copyable={false}
>
{meta.label}
<span className='text-muted-foreground/30'>·</span>
<span className='text-muted-foreground font-mono'>
#{group.id}
</span>
</StatusBadge>
</CardTitle>
{group.description ? (
<CardDescription className='line-clamp-2'>
{group.description}
</CardDescription>
) : (
<CardDescription className='text-muted-foreground italic'>
No description provided
</CardDescription>
)}
</div>
</DialogDescription>
</DialogHeader>
<div className='flex items-center gap-2'>
<Button
size='icon'
variant='outline'
onClick={() => onEditGroup(group)}
>
<Pencil className='h-4 w-4' />
<span className='sr-only'>Edit group</span>
</Button>
<Button
size='icon'
variant='ghost'
className='text-destructive hover:text-destructive'
onClick={() => handleDeleteClick(group)}
>
<Trash2 className='h-4 w-4' />
<span className='sr-only'>Delete group</span>
</Button>
</div>
</CardHeader>
<CardContent className='space-y-3'>
<div className='text-muted-foreground flex flex-wrap items-center gap-2 text-xs font-medium tracking-wide uppercase'>
<span>Items</span>
<StatusBadge
label={`${parsedItems.length} item${parsedItems.length === 1 ? '' : 's'}`}
variant='neutral'
size='sm'
copyable={false}
/>
</div>
{parsedItems.length > 0 ? (
<div className='flex flex-wrap gap-2'>
{parsedItems.slice(0, 6).map((item) => (
<StatusBadge
key={item}
label={item}
autoColor={item}
size='sm'
/>
))}
{parsedItems.length > 6 && (
<StatusBadge
label={`+${parsedItems.length - 6} more`}
variant='neutral'
size='sm'
copyable={false}
/>
)}
</div>
) : (
<p className='text-muted-foreground text-sm'>
{group.type === 'endpoint'
? 'No endpoint mappings configured.'
: 'No items configured yet.'}
</p>
)}
</CardContent>
</Card>
))}
<DialogClose
render={
<Button
variant='ghost'
size='icon'
className='text-muted-foreground hover:text-foreground absolute top-4 right-4 border border-transparent sm:top-5 sm:right-6'
/>
}
>
<span className='sr-only'>{t('Close dialog')}</span>
<X className='h-4 w-4' />
</DialogClose>
</div>
) : (
<div className='rounded-md border'>
<div className='w-full overflow-x-auto'>
<Table className='min-w-[680px]'>
<TableHeader>
<TableRow>
<TableHead>{t('Group')}</TableHead>
<TableHead>{t('Type')}</TableHead>
<TableHead className='min-w-[240px]'>
{t('Items')}
</TableHead>
<TableHead className='w-[120px] text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{normalizedGroups.map(({ group, meta, parsedItems }) => (
<TableRow key={group.id}>
<TableCell className='align-top whitespace-normal'>
<div className='flex flex-col gap-1'>
<div className='flex flex-wrap items-center gap-2'>
<span className='font-medium'>{group.name}</span>
<TableId value={group.id} />
<div className='flex flex-wrap items-center gap-3 border-b px-4 py-3 text-sm sm:px-6'>
<div className='flex flex-wrap items-center gap-2'>
<Button size='sm' onClick={onCreateGroup}>
<Plus className='mr-2 h-4 w-4' />
{t('New Group')}
</Button>
<Button
size='sm'
variant='ghost'
onClick={() => refetchGroups()}
disabled={isFetching}
>
{isFetching ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCcw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
</div>
<StatusBadge
label={`${groups.length} group${groups.length === 1 ? '' : 's'}`}
variant='neutral'
copyable={false}
/>
</div>
<div
className={cn(
'flex flex-1 flex-col overflow-hidden px-4 py-4 sm:px-6 sm:py-6',
isMobile && 'pb-[calc(env(safe-area-inset-bottom,0px)+1.5rem)]'
)}
>
<div className='flex-1 overflow-y-auto'>
<div className='flex flex-col gap-4'>
{error && (
<Alert variant='destructive'>
<AlertTitle>{t('Unable to load groups')}</AlertTitle>
<AlertDescription>
{(error as Error).message ||
'Please retry or refresh the page.'}
</AlertDescription>
</Alert>
)}
{isLoading ? (
<div className='flex flex-col items-center justify-center gap-2 py-16 text-center'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
<p className='text-muted-foreground text-sm'>
{t('Fetching prefill groups...')}
</p>
</div>
) : normalizedGroups.length === 0 ? (
<Empty className='border border-dashed'>
<EmptyMedia variant='icon'>
<Layers3 className='h-6 w-6' />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>{t('No prefill groups yet')}</EmptyTitle>
<EmptyDescription>
{t(
'Create your first group to reuse model, tag, or endpoint selections anywhere in the dashboard.'
)}
</EmptyDescription>
</EmptyHeader>
<EmptyDescription>
{t(
'Prefill groups help you keep complex configurations in sync.'
)}
</EmptyDescription>
</Empty>
) : isMobile ? (
<div className='space-y-4'>
{normalizedGroups.map(({ group, meta, parsedItems }) => (
<Card key={group.id} className='border-border/60'>
<CardHeader className='flex flex-row items-start justify-between gap-4'>
<div className='space-y-2'>
<CardTitle className='flex flex-wrap items-center gap-2'>
{group.name}
<StatusBadge
variant={meta.badge}
size='sm'
copyable={false}
>
{meta.label}
<span className='text-muted-foreground/30'>
·
</span>
<span className='text-muted-foreground font-mono'>
#{group.id}
</span>
</StatusBadge>
</CardTitle>
{group.description ? (
<CardDescription className='line-clamp-2'>
{group.description}
</CardDescription>
) : (
<CardDescription className='text-muted-foreground italic'>
No description provided
</CardDescription>
)}
</div>
<div className='flex items-center gap-2'>
<Button
size='icon'
variant='outline'
onClick={() => onEditGroup(group)}
>
<Pencil className='h-4 w-4' />
<span className='sr-only'>Edit group</span>
</Button>
<Button
size='icon'
variant='ghost'
className='text-destructive hover:text-destructive'
onClick={() => handleDeleteClick(group)}
>
<Trash2 className='h-4 w-4' />
<span className='sr-only'>Delete group</span>
</Button>
</div>
</CardHeader>
<CardContent className='space-y-3'>
<div className='text-muted-foreground flex flex-wrap items-center gap-2 text-xs font-medium tracking-wide uppercase'>
<span>Items</span>
<StatusBadge
label={`${parsedItems.length} item${parsedItems.length === 1 ? '' : 's'}`}
variant='neutral'
size='sm'
copyable={false}
/>
</div>
{group.description ? (
<p className='text-muted-foreground text-xs'>
{group.description}
</p>
) : (
<p className='text-muted-foreground text-xs italic'>
No description provided
</p>
)}
</div>
</TableCell>
<TableCell className='align-top'>
<StatusBadge
label={meta.label}
variant={meta.badge}
size='sm'
copyable={false}
/>
</TableCell>
<TableCell className='align-top whitespace-normal'>
<div className='flex flex-wrap gap-2'>
{parsedItems.length > 0 ? (
<>
<div className='flex flex-wrap gap-2'>
{parsedItems.slice(0, 6).map((item) => (
<StatusBadge
key={item}
@@ -407,7 +368,7 @@ export function PrefillGroupManagementDialog({
copyable={false}
/>
)}
</>
</div>
) : (
<p className='text-muted-foreground text-sm'>
{group.type === 'endpoint'
@@ -415,41 +376,131 @@ export function PrefillGroupManagementDialog({
: 'No items configured yet.'}
</p>
)}
</div>
<div className='text-muted-foreground mt-2 text-xs font-medium tracking-wide uppercase'>
{parsedItems.length} item
{parsedItems.length === 1 ? '' : 's'}
</div>
</TableCell>
<TableCell className='align-top'>
<div className='flex justify-end gap-2'>
<Button
size='icon'
variant='outline'
onClick={() => onEditGroup(group)}
>
<Pencil className='h-4 w-4' />
<span className='sr-only'>Edit group</span>
</Button>
<Button
size='icon'
variant='ghost'
className='text-destructive hover:text-destructive'
onClick={() => handleDeleteClick(group)}
>
<Trash2 className='h-4 w-4' />
<span className='sr-only'>Delete group</span>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
))}
</div>
) : (
<div className='rounded-md border'>
<div className='w-full overflow-x-auto'>
<Table className='min-w-[720px]'>
<TableHeader>
<TableRow>
<TableHead>{t('Group')}</TableHead>
<TableHead>{t('Type')}</TableHead>
<TableHead className='min-w-[280px]'>
{t('Items')}
</TableHead>
<TableHead className='w-[120px] text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{normalizedGroups.map(
({ group, meta, parsedItems }) => (
<TableRow key={group.id}>
<TableCell className='align-top whitespace-normal'>
<div className='flex flex-col gap-1'>
<div className='flex flex-wrap items-center gap-2'>
<span className='font-medium'>
{group.name}
</span>
<TableId value={group.id} />
</div>
{group.description ? (
<p className='text-muted-foreground text-xs'>
{group.description}
</p>
) : (
<p className='text-muted-foreground text-xs italic'>
No description provided
</p>
)}
</div>
</TableCell>
<TableCell className='align-top'>
<StatusBadge
label={meta.label}
variant={meta.badge}
size='sm'
copyable={false}
/>
</TableCell>
<TableCell className='align-top whitespace-normal'>
<div className='flex flex-wrap gap-2'>
{parsedItems.length > 0 ? (
<>
{parsedItems
.slice(0, 6)
.map((item) => (
<StatusBadge
key={item}
label={item}
autoColor={item}
size='sm'
/>
))}
{parsedItems.length > 6 && (
<StatusBadge
label={`+${parsedItems.length - 6} more`}
variant='neutral'
size='sm'
copyable={false}
/>
)}
</>
) : (
<p className='text-muted-foreground text-sm'>
{group.type === 'endpoint'
? 'No endpoint mappings configured.'
: 'No items configured yet.'}
</p>
)}
</div>
<div className='text-muted-foreground mt-2 text-xs font-medium tracking-wide uppercase'>
{parsedItems.length} item
{parsedItems.length === 1 ? '' : 's'}
</div>
</TableCell>
<TableCell className='align-top'>
<div className='flex justify-end gap-2'>
<Button
size='icon'
variant='outline'
onClick={() => onEditGroup(group)}
>
<Pencil className='h-4 w-4' />
<span className='sr-only'>
Edit group
</span>
</Button>
<Button
size='icon'
variant='ghost'
className='text-destructive hover:text-destructive'
onClick={() => handleDeleteClick(group)}
>
<Trash2 className='h-4 w-4' />
<span className='sr-only'>
Delete group
</span>
</Button>
</div>
</TableCell>
</TableRow>
)
)}
</TableBody>
</Table>
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
<ConfirmDialog
@@ -22,8 +22,14 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog'
import { checkClusterNameAvailability, updateDeploymentName } from '../../api'
import { deploymentsQueryKeys } from '../../lib'
@@ -105,16 +111,27 @@ export function RenameDeploymentDialog({
}
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Rename deployment')}
contentClassName='sm:max-w-lg'
footerClassName='mt-4'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Rename deployment')}</DialogTitle>
</DialogHeader>
<div className='space-y-2'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}:{' '}
<span className='font-mono'>{deploymentId}</span>
</div>
<Input
placeholder={t('Enter a new name')}
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete='off'
/>
<div className='text-muted-foreground text-xs'>{helper}</div>
</div>
<DialogFooter className='mt-4'>
<Button variant='outline' onClick={() => onOpenChange(false)}>
{t('Cancel')}
</Button>
@@ -124,22 +141,8 @@ export function RenameDeploymentDialog({
) : null}
{t('Rename')}
</Button>
</>
}
>
<div className='space-y-2'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}:{' '}
<span className='font-mono'>{deploymentId}</span>
</div>
<Input
placeholder={t('Enter a new name')}
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete='off'
/>
<div className='text-muted-foreground text-xs'>{helper}</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -24,9 +24,16 @@ import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { useIsMobile } from '@/hooks/use-mobile'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { syncUpstream, previewUpstreamDiff } from '../../api'
import { getSyncLocaleOptions, getSyncSourceOptions } from '../../constants'
@@ -118,16 +125,117 @@ export function SyncWizardDialog({
}
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Sync Upstream Models')}
description={t('Synchronize models and vendors from an upstream source')}
initialFocus={!isMobile}
contentHeight='auto'
bodyClassName='flex flex-col gap-6'
footer={
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className='flex max-h-[90vh] w-full flex-col gap-4 p-4 sm:max-w-2xl sm:p-6'
initialFocus={!isMobile}
>
<DialogHeader className='flex-shrink-0 text-start'>
<DialogTitle>{t('Sync Upstream Models')}</DialogTitle>
<DialogDescription>
{t('Synchronize models and vendors from an upstream source')}
</DialogDescription>
</DialogHeader>
<div className='flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto'>
<div className='space-y-3'>
<div>
<Label className='text-base'>{t('Select Sync Source')}</Label>
<p className='text-muted-foreground text-sm'>
{t('Choose where to fetch upstream metadata.')}
</p>
</div>
<RadioGroup
value={source}
onValueChange={(value) => {
const selected = SYNC_SOURCE_OPTIONS.find(
(option) => option.value === value
)
if (!selected || selected.disabled) return
setSource(selected.value)
}}
className='grid gap-3 md:grid-cols-2'
>
{SYNC_SOURCE_OPTIONS.map((option) => {
const isActive = source === option.value
const isDisabled = option.disabled
return (
<Label
key={option.value}
htmlFor={`sync-source-${option.value}`}
className={cn(
'flex-col items-start gap-0 rounded-lg border p-4 font-normal transition-all',
isActive && 'border-primary ring-primary ring-1',
isDisabled
? 'cursor-not-allowed opacity-60'
: 'hover:border-primary/60 cursor-pointer'
)}
>
<div className='flex items-start gap-3'>
<RadioGroupItem
value={option.value}
id={`sync-source-${option.value}`}
disabled={isDisabled}
/>
<div className='space-y-1'>
<div className='flex items-center gap-2'>
<span className='font-medium'>{option.label}</span>
{option.value === 'official' && (
<StatusBadge
label='Default'
variant='neutral'
copyable={false}
/>
)}
</div>
<p className='text-muted-foreground text-sm'>
{option.description}
</p>
</div>
</div>
</Label>
)
})}
</RadioGroup>
</div>
<div className='space-y-2'>
<Label className='text-base'>{t('Select Language')}</Label>
<RadioGroup
value={locale}
onValueChange={(v) => setLocale(v as SyncLocale)}
className='grid gap-3 sm:grid-cols-3'
>
{SYNC_LOCALE_OPTIONS.map((option) => (
<div
key={option.value}
className='flex items-center space-x-2 rounded-lg border p-3'
>
<RadioGroupItem
value={option.value}
id={`locale-${option.value}`}
/>
<Label
htmlFor={`locale-${option.value}`}
className='cursor-pointer font-normal'
>
{option.label}
</Label>
</div>
))}
</RadioGroup>
</div>
<div className='bg-muted/50 rounded-lg border p-4'>
<p className='text-muted-foreground text-sm'>
{t(
'The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.'
)}
</p>
</div>
</div>
<DialogFooter className='flex-shrink-0 gap-2 sm:justify-end'>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
@@ -138,106 +246,10 @@ export function SyncWizardDialog({
<Button onClick={handleSync} disabled={isSyncing}>
{isSyncing && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
<RefreshCw className='mr-2 h-4 w-4' />
{isSyncing ? t('Syncing...') : t('Sync Now')}
{isSyncing ? 'Syncing...' : 'Sync Now'}
</Button>
</>
}
>
<div className='space-y-3'>
<div>
<Label className='text-base'>{t('Select Sync Source')}</Label>
<p className='text-muted-foreground text-sm'>
{t('Choose where to fetch upstream metadata.')}
</p>
</div>
<RadioGroup
value={source}
onValueChange={(value) => {
const selected = SYNC_SOURCE_OPTIONS.find(
(option) => option.value === value
)
if (!selected || selected.disabled) return
setSource(selected.value)
}}
className='grid gap-3 md:grid-cols-2'
>
{SYNC_SOURCE_OPTIONS.map((option) => {
const isActive = source === option.value
const isDisabled = option.disabled
return (
<Label
key={option.value}
htmlFor={`sync-source-${option.value}`}
className={cn(
'flex-col items-start gap-0 rounded-lg border p-4 font-normal transition-all',
isActive && 'border-primary ring-primary ring-1',
isDisabled
? 'cursor-not-allowed opacity-60'
: 'hover:border-primary/60 cursor-pointer'
)}
>
<div className='flex items-start gap-3'>
<RadioGroupItem
value={option.value}
id={`sync-source-${option.value}`}
disabled={isDisabled}
/>
<div className='space-y-1'>
<div className='flex items-center gap-2'>
<span className='font-medium'>{option.label}</span>
{option.value === 'official' && (
<StatusBadge
label='Default'
variant='neutral'
copyable={false}
/>
)}
</div>
<p className='text-muted-foreground text-sm'>
{option.description}
</p>
</div>
</div>
</Label>
)
})}
</RadioGroup>
</div>
<div className='space-y-2'>
<Label className='text-base'>{t('Select Language')}</Label>
<RadioGroup
value={locale}
onValueChange={(v) => setLocale(v as SyncLocale)}
className='grid gap-3 sm:grid-cols-3'
>
{SYNC_LOCALE_OPTIONS.map((option) => (
<div
key={option.value}
className='flex items-center space-x-2 rounded-lg border p-3'
>
<RadioGroupItem
value={option.value}
id={`locale-${option.value}`}
/>
<Label
htmlFor={`locale-${option.value}`}
className='cursor-pointer font-normal'
>
{option.label}
</Label>
</div>
))}
</RadioGroup>
</div>
<div className='bg-muted/50 rounded-lg border p-4'>
<p className='text-muted-foreground text-sm'>
{t(
'The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.'
)}
</p>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -30,6 +30,13 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -40,7 +47,6 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { getDeployment, updateDeployment } from '../../api'
import { deploymentsQueryKeys } from '../../lib'
@@ -58,8 +64,6 @@ const schema = z.object({
type Values = z.input<typeof schema>
const UPDATE_CONFIG_FORM_ID = 'update-config-form'
function normalizeJsonObject(input?: string) {
if (!input || !input.trim()) return undefined
const parsed = JSON.parse(input)
@@ -208,228 +212,226 @@ export function UpdateConfigDialog({
}
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={title}
contentClassName='max-h-[calc(100dvh-2rem)] overflow-hidden max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
isLoading ? null : (
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button
type='submit'
form={UPDATE_CONFIG_FORM_ID}
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{t('Update')}
</Button>
</>
)
}
>
{isLoading ? (
<div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : (
<div className='max-h-[calc(100dvh-8.5rem)] overflow-y-auto py-2 pr-1 sm:max-h-[72vh]'>
<Form {...form}>
<form
id={UPDATE_CONFIG_FORM_ID}
onSubmit={form.handleSubmit(onSubmit)}
autoComplete='off'
className='space-y-4'
>
<div className='grid gap-3 md:grid-cols-2 md:gap-4'>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-h-[calc(100dvh-2rem)] overflow-hidden max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-3xl'>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{isLoading ? (
<div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : (
<div className='max-h-[calc(100dvh-8.5rem)] overflow-y-auto py-2 pr-1 sm:max-h-[72vh]'>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
autoComplete='off'
className='space-y-4'
>
<div className='grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='image_url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Image')}</FormLabel>
<FormControl>
<Input
placeholder='ollama/ollama:latest'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='traffic_port'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Port')}</FormLabel>
<FormControl>
<Input
type='number'
min={1}
max={65535}
value={
typeof field.value === 'number' ||
typeof field.value === 'string'
? field.value
: ''
}
onChange={(e) => {
const v = e.target.value
field.onChange(v === '' ? undefined : Number(v))
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='entrypoint'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Entrypoint (space separated)')}
</FormLabel>
<FormControl>
<Input placeholder='bash -lc' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='args'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Args (space separated)')}</FormLabel>
<FormControl>
<Input placeholder='--foo bar' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='image_url'
name='command'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Image')}</FormLabel>
<FormLabel>{t('Command')}</FormLabel>
<FormControl>
<Input placeholder='ollama/ollama:latest' {...field} />
<Input placeholder='Optional' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='traffic_port'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Port')}</FormLabel>
<FormControl>
<Input
type='number'
min={1}
max={65535}
value={
typeof field.value === 'number' ||
typeof field.value === 'string'
? field.value
: ''
}
onChange={(e) => {
const v = e.target.value
field.onChange(v === '' ? undefined : Number(v))
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Collapsible className='rounded-md border p-3'>
<CollapsibleTrigger className='cursor-pointer text-sm'>
{t('Registry (optional)')}
</CollapsibleTrigger>
<CollapsibleContent>
<div className='mt-3 grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='registry_username'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Registry username')}</FormLabel>
<FormControl>
<Input autoComplete='off' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='registry_secret'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Registry secret')}</FormLabel>
<FormControl>
<Input
type='password'
autoComplete='off'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</Collapsible>
<div className='grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='entrypoint'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Entrypoint (space separated)')}</FormLabel>
<FormControl>
<Input placeholder='bash -lc' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Collapsible className='rounded-md border p-3'>
<CollapsibleTrigger className='cursor-pointer text-sm'>
{t('Environment variables')}
</CollapsibleTrigger>
<CollapsibleContent>
<div className='mt-3 grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='env_json'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Env (JSON object)')}</FormLabel>
<FormControl>
<Textarea
className='min-h-40 font-mono text-xs'
placeholder='{"KEY":"VALUE"}'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='secret_env_json'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Secret env (JSON object)')}
</FormLabel>
<FormControl>
<Textarea
className='min-h-40 font-mono text-xs'
placeholder='{"SECRET":"VALUE"}'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</Collapsible>
<FormField
control={form.control}
name='args'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Args (space separated)')}</FormLabel>
<FormControl>
<Input placeholder='--foo bar' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='command'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Command')}</FormLabel>
<FormControl>
<Input placeholder='Optional' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Collapsible className='rounded-md border p-3'>
<CollapsibleTrigger className='cursor-pointer text-sm'>
{t('Registry (optional)')}
</CollapsibleTrigger>
<CollapsibleContent>
<div className='mt-3 grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='registry_username'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Registry username')}</FormLabel>
<FormControl>
<Input autoComplete='off' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='registry_secret'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Registry secret')}</FormLabel>
<FormControl>
<Input
type='password'
autoComplete='off'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</Collapsible>
<Collapsible className='rounded-md border p-3'>
<CollapsibleTrigger className='cursor-pointer text-sm'>
{t('Environment variables')}
</CollapsibleTrigger>
<CollapsibleContent>
<div className='mt-3 grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='env_json'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Env (JSON object)')}</FormLabel>
<FormControl>
<Textarea
className='min-h-40 font-mono text-xs'
placeholder='{"KEY":"VALUE"}'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='secret_env_json'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Secret env (JSON object)')}</FormLabel>
<FormControl>
<Textarea
className='min-h-40 font-mono text-xs'
placeholder='{"SECRET":"VALUE"}'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</Collapsible>
</form>
</Form>
</div>
)}
<DialogFooter className='grid grid-cols-2 gap-2 pt-2 sm:flex'>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='submit' disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{t('Update')}
</Button>
</DialogFooter>
</form>
</Form>
</div>
)}
</DialogContent>
</Dialog>
)
}
@@ -37,6 +37,14 @@ import { toast } from 'sonner'
import { useIsMobile } from '@/hooks/use-mobile'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
Popover,
@@ -59,7 +67,6 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { applyUpstreamOverwrite } from '../../api'
import { modelsQueryKeys, vendorsQueryKeys } from '../../lib'
@@ -446,217 +453,222 @@ export function UpstreamConflictDialog({
}
onOpenChange(nextOpen)
}}
title={t('Resolve Conflicts')}
description={t(
'Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.'
)}
contentClassName='w-full sm:max-w-5xl'
contentHeight='min(72vh, 720px)'
bodyClassName='flex flex-col gap-4'
initialFocus={!isMobile}
footerClassName='sm:justify-between'
footer={
<div className='flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<div className='text-muted-foreground flex flex-1 items-start gap-2 text-xs'>
<Info className='h-4 w-4 flex-shrink-0' />
<span>
{t(
'Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.'
)}
</span>
</div>
<div className='flex flex-col gap-2 sm:flex-row sm:justify-end'>
<Button
variant='outline'
onClick={() => {
setUpstreamConflicts([])
onOpenChange(false)
}}
>
{t('Cancel')}
</Button>
<Button
onClick={handleApplyOverwrite}
disabled={isSubmitting || !hasSelection}
>
{isSubmitting ? t('Applying...') : t('Apply Overwrite')}
</Button>
</div>
</div>
}
>
<div className='flex min-h-0 flex-1 flex-col gap-4'>
{!hasConflicts ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center rounded-md border border-dashed p-8 text-center text-sm'>
{t('No conflict entries available.')}
</div>
) : (
<div className='flex min-h-0 flex-1 flex-col gap-4 overflow-hidden'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<div className='space-y-1'>
<div className='text-sm font-medium'>
{visibleModelCount} {t('model')}
{visibleModelCount === 1 ? '' : 's'} {t('with conflicts')}
</div>
<div className='text-muted-foreground text-xs'>
{visibleFieldCount} {t('field')}
{visibleFieldCount === 1 ? '' : 's'} {t('showing •')}{' '}
{totalSelectedFields} {t('selected')}
</div>
</div>
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row'>
<div className='relative flex-1'>
<Search className='text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
value={search}
onChange={(event) => {
setSearch(event.target.value)
setPageIndex(0)
}}
placeholder={t('Search models or fields...')}
className='pl-9'
aria-label={t('Search conflicting models or fields')}
/>
</div>
<Button
variant='ghost'
size='sm'
onClick={clearSelections}
disabled={!hasSelection}
>
{t('Clear selection')}
</Button>
</div>
<DialogContent
className='flex max-h-[90vh] w-full flex-col gap-4 p-4 sm:max-w-5xl sm:p-6'
initialFocus={!isMobile}
>
<div className='flex min-h-0 flex-1 flex-col gap-4 overflow-hidden'>
<DialogHeader className='flex-shrink-0 text-start'>
<DialogTitle>{t('Resolve Conflicts')}</DialogTitle>
<DialogDescription>
{t(
'Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.'
)}
</DialogDescription>
</DialogHeader>
{!hasConflicts ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center rounded-md border border-dashed p-8 text-center text-sm'>
{t('No conflict entries available.')}
</div>
{showSearchEmptyState ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center rounded-md border border-dashed p-8 text-center text-sm'>
{t('No conflicts match your search.')}
</div>
) : (
<div className='flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border'>
<div className='flex-1 overflow-auto'>
<div className={isMobile ? 'min-w-full' : 'min-w-[720px]'}>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{paginatedRows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className='flex min-h-0 flex-1 flex-col gap-4 overflow-hidden'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<div className='space-y-1'>
<div className='text-sm font-medium'>
{visibleModelCount} {t('model')}
{visibleModelCount === 1 ? '' : 's'} {t('with conflicts')}
</div>
</div>
<div className='bg-muted/40 flex flex-col gap-2 border-t px-2 py-1.5 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-3 sm:px-3 sm:py-2'>
<div className='text-muted-foreground text-xs'>
{t('Showing')} {displayStart}-{displayEnd} {t('of')}{' '}
{visibleFieldCount} {t('field')}
{visibleFieldCount === 1 ? '' : 's'}
{visibleFieldCount === 1 ? '' : 's'} {t('showing •')}{' '}
{totalSelectedFields} {t('selected')}
</div>
<div className='flex items-center justify-between gap-2 sm:flex-wrap sm:gap-3'>
<div className='flex items-center gap-1.5 text-xs sm:gap-2'>
<span className='hidden sm:inline'>
{t('Rows per page')}
</span>
<Select
items={[
...[5, 10, 20, 50].map((size) => ({
value: String(size),
label: size,
})),
]}
value={String(pageSize)}
onValueChange={(value) => {
setPageSize(Number(value))
setPageIndex(0)
}}
>
<SelectTrigger className='h-8 w-[70px] text-xs sm:h-8 sm:w-[72px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{[5, 10, 20, 50].map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row'>
<div className='relative flex-1'>
<Search className='text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
value={search}
onChange={(event) => {
setSearch(event.target.value)
setPageIndex(0)
}}
placeholder={t('Search models or fields...')}
className='pl-9'
aria-label={t('Search conflicting models or fields')}
/>
</div>
<Button
variant='ghost'
size='sm'
onClick={clearSelections}
disabled={!hasSelection}
>
{t('Clear selection')}
</Button>
</div>
</div>
{showSearchEmptyState ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center rounded-md border border-dashed p-8 text-center text-sm'>
{t('No conflicts match your search.')}
</div>
) : (
<div className='flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border'>
<div className='flex-1 overflow-auto'>
<div className={isMobile ? 'min-w-full' : 'min-w-[720px]'}>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{paginatedRows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className='flex items-center gap-1'>
<Button
variant='outline'
size='icon'
className='h-7 w-7 sm:h-8 sm:w-8'
onClick={() =>
setPageIndex((prev) => Math.max(0, prev - 1))
}
disabled={pageIndex === 0}
aria-label={t('Previous page')}
>
<ChevronLeft className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
</Button>
<span className='text-xs font-medium'>
{t('Page {{current}} of {{total}}', {
current: currentPageDisplay,
total: totalPagesDisplay,
})}
</span>
<Button
variant='outline'
size='icon'
className='h-7 w-7 sm:h-8 sm:w-8'
onClick={() =>
setPageIndex((prev) =>
Math.min(totalPages - 1, prev + 1)
)
}
disabled={
pageIndex >= totalPages - 1 ||
totalFilteredFields === 0
}
aria-label={t('Next page')}
>
<ChevronRight className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
</Button>
</div>
<div className='bg-muted/40 flex flex-col gap-2 border-t px-2 py-1.5 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-3 sm:px-3 sm:py-2'>
<div className='text-muted-foreground text-xs'>
{t('Showing')} {displayStart}-{displayEnd} {t('of')}{' '}
{visibleFieldCount} {t('field')}
{visibleFieldCount === 1 ? '' : 's'}
</div>
<div className='flex items-center justify-between gap-2 sm:flex-wrap sm:gap-3'>
<div className='flex items-center gap-1.5 text-xs sm:gap-2'>
<span className='hidden sm:inline'>
{t('Rows per page')}
</span>
<Select
items={[
...[5, 10, 20, 50].map((size) => ({
value: String(size),
label: size,
})),
]}
value={String(pageSize)}
onValueChange={(value) => {
setPageSize(Number(value))
setPageIndex(0)
}}
>
<SelectTrigger className='h-8 w-[70px] text-xs sm:h-8 sm:w-[72px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{[5, 10, 20, 50].map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='flex items-center gap-1'>
<Button
variant='outline'
size='icon'
className='h-7 w-7 sm:h-8 sm:w-8'
onClick={() =>
setPageIndex((prev) => Math.max(0, prev - 1))
}
disabled={pageIndex === 0}
aria-label={t('Previous page')}
>
<ChevronLeft className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
</Button>
<span className='text-xs font-medium'>
{t('Page {{current}} of {{total}}', {
current: currentPageDisplay,
total: totalPagesDisplay,
})}
</span>
<Button
variant='outline'
size='icon'
className='h-7 w-7 sm:h-8 sm:w-8'
onClick={() =>
setPageIndex((prev) =>
Math.min(totalPages - 1, prev + 1)
)
}
disabled={
pageIndex >= totalPages - 1 ||
totalFilteredFields === 0
}
aria-label={t('Next page')}
>
<ChevronRight className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
</Button>
</div>
</div>
</div>
</div>
</div>
)}
)}
</div>
)}
</div>
<DialogFooter className='flex-shrink-0'>
<div className='flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<div className='text-muted-foreground flex flex-1 items-start gap-2 text-xs'>
<Info className='h-4 w-4 flex-shrink-0' />
<span>
{t(
'Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.'
)}
</span>
</div>
<div className='flex flex-col gap-2 sm:flex-row sm:justify-end'>
<Button
variant='outline'
onClick={() => {
setUpstreamConflicts([])
onOpenChange(false)
}}
>
{t('Cancel')}
</Button>
<Button
onClick={handleApplyOverwrite}
disabled={isSubmitting || !hasSelection}
>
{isSubmitting ? t('Applying...') : t('Apply Overwrite')}
</Button>
</div>
</div>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -24,6 +24,14 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -35,7 +43,6 @@ import {
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { createVendor, updateVendor } from '../../api'
import { vendorsQueryKeys, modelsQueryKeys } from '../../lib'
import { vendorFormSchema, type Vendor } from '../../types'
@@ -46,8 +53,6 @@ type VendorMutateDialogProps = {
currentVendor?: Vendor | null
}
const VENDOR_MUTATE_FORM_ID = 'vendor-mutate-form'
export function VendorMutateDialog({
open,
onOpenChange,
@@ -113,107 +118,98 @@ export function VendorMutateDialog({
}
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={isEdit ? t('Edit Vendor') : t('Create Vendor')}
description={
isEdit
? t('Update vendor information for {{name}}', {
name: currentVendor?.name,
})
: t('Add a new vendor to the system')
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
{t('Cancel')}
</Button>
<Button
type='submit'
form={VENDOR_MUTATE_FORM_ID}
disabled={isSaving}
>
{isSaving ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{isSaving ? t('Saving...') : isEdit ? t('Update') : t('Create')}
</Button>
</>
}
>
<Form {...form}>
<form
id={VENDOR_MUTATE_FORM_ID}
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-4'
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Vendor Name *')}</FormLabel>
<FormControl>
<Input
placeholder={t('OpenAI, Anthropic, etc.')}
{...field}
/>
</FormControl>
<FormDescription>
{t('The unique name for this vendor')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isEdit ? t('Edit Vendor') : t('Create Vendor')}
</DialogTitle>
<DialogDescription>
{isEdit
? t('Update vendor information for {{name}}', {
name: currentVendor?.name,
})
: t('Add a new vendor to the system')}
</DialogDescription>
</DialogHeader>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Description')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('Describe this vendor...')}
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Vendor Name *')}</FormLabel>
<FormControl>
<Input
placeholder={t('OpenAI, Anthropic, etc.')}
{...field}
/>
</FormControl>
<FormDescription>
{t('The unique name for this vendor')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='icon'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Icon')}</FormLabel>
<FormControl>
<Input
placeholder={t('OpenAI, Anthropic, Google, etc.')}
{...field}
/>
</FormControl>
<FormDescription>
{t('@lobehub/icons key name')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Description')}</FormLabel>
<FormControl>
<Textarea
placeholder={t('Describe this vendor...')}
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='icon'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Icon')}</FormLabel>
<FormControl>
<Input
placeholder={t('OpenAI, Anthropic, Google, etc.')}
{...field}
/>
</FormControl>
<FormDescription>
{t('@lobehub/icons key name')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
{t('Cancel')}
</Button>
<Button type='submit' disabled={isSaving}>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isSaving ? 'Saving...' : isEdit ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}
@@ -27,8 +27,14 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Separator } from '@/components/ui/separator'
import { Dialog } from '@/components/dialog'
import { getDeployment, listDeploymentContainers } from '../../api'
export function ViewDetailsDialog({
@@ -110,15 +116,160 @@ export function ViewDetailsDialog({
}, [details])
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Deployment details')}
contentClassName='max-h-[calc(100dvh-2rem)] overflow-hidden max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-h-[calc(100dvh-2rem)] overflow-hidden max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-3xl'>
<DialogHeader>
<DialogTitle>{t('Deployment details')}</DialogTitle>
</DialogHeader>
<div className='max-h-[calc(100dvh-8.5rem)] space-y-3 overflow-y-auto py-2 pr-1 sm:max-h-[72vh] sm:space-y-4'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}:{' '}
<span className='font-mono'>{deploymentId}</span>
</div>
<div className='grid grid-cols-2 gap-2 sm:flex sm:items-center'>
<Button variant='outline' size='sm' onClick={handleCopyId}>
<Copy className='mr-2 h-4 w-4' />
{t('Copy')}
</Button>
<Button
variant='outline'
size='sm'
onClick={handleRefresh}
disabled={isFetchingDetails || isFetchingContainers}
>
{isFetchingDetails || isFetchingContainers ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCcw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
</div>
</div>
<Separator />
{isLoadingDetails || isLoadingContainers ? (
<div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : !detailsRes?.success ? (
<div className='text-muted-foreground py-10 text-center text-sm'>
{detailsRes?.message || t('Failed to fetch deployment details')}
</div>
) : (
<>
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Status')}
</div>
<div className='mt-1 font-medium'>
{String(details?.status ?? '-')}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Hardware')}
</div>
<div className='mt-1 font-medium'>
{String(details?.brand_name ?? '')}{' '}
{String(details?.hardware_name ?? '')}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Total GPUs')}
</div>
<div className='mt-1 font-medium'>
{String(
details?.total_gpus ?? details?.hardware_qty ?? '-'
)}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Containers')}
</div>
<div className='mt-1 font-medium'>{containers.length}</div>
</div>
</div>
{locations.length ? (
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Locations')}
</div>
<div className='mt-1 flex flex-wrap gap-2 text-sm'>
{locations.map((x) => (
<span key={x} className='bg-muted rounded-md px-2 py-1'>
{x}
</span>
))}
</div>
</div>
) : null}
{containers.length ? (
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground mb-2 text-xs'>
{t('Containers')}
</div>
<div className='space-y-2'>
{containers.map((c) => {
const id = c?.container_id
if (typeof id !== 'string' || !id) return null
const status =
typeof c?.status === 'string' ? c.status : undefined
const url =
typeof c?.public_url === 'string' ? c.public_url : ''
return (
<div
key={id}
className='flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2'
>
<div className='min-w-0'>
<div className='truncate font-mono text-sm'>
{id}
</div>
<div className='text-muted-foreground text-xs'>
{status ? `${t('Status')}: ${status}` : ''}
</div>
</div>
{url ? (
<Button
variant='outline'
size='sm'
onClick={() => window.open(url, '_blank')}
>
<ExternalLink className='mr-2 h-4 w-4' />
{t('Open')}
</Button>
) : null}
</div>
)
})}
</div>
</div>
) : null}
<Collapsible className='rounded-lg border p-3'>
<CollapsibleTrigger className='cursor-pointer text-sm font-medium'>
{t('Raw JSON')}
</CollapsibleTrigger>
<CollapsibleContent>
<pre className='mt-3 max-h-[360px] overflow-auto rounded-md bg-black p-3 text-xs text-gray-200'>
{payloadJson || '-'}
</pre>
</CollapsibleContent>
</Collapsible>
</>
)}
</div>
<DialogFooter>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
@@ -126,151 +277,8 @@ export function ViewDetailsDialog({
>
{t('Close')}
</Button>
</>
}
>
<div className='max-h-[calc(100dvh-8.5rem)] space-y-3 overflow-y-auto py-2 pr-1 sm:max-h-[72vh] sm:space-y-4'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}:{' '}
<span className='font-mono'>{deploymentId}</span>
</div>
<div className='grid grid-cols-2 gap-2 sm:flex sm:items-center'>
<Button variant='outline' size='sm' onClick={handleCopyId}>
<Copy className='mr-2 h-4 w-4' />
{t('Copy')}
</Button>
<Button
variant='outline'
size='sm'
onClick={handleRefresh}
disabled={isFetchingDetails || isFetchingContainers}
>
{isFetchingDetails || isFetchingContainers ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCcw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
</div>
</div>
<Separator />
{isLoadingDetails || isLoadingContainers ? (
<div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : !detailsRes?.success ? (
<div className='text-muted-foreground py-10 text-center text-sm'>
{detailsRes?.message || t('Failed to fetch deployment details')}
</div>
) : (
<>
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Status')}
</div>
<div className='mt-1 font-medium'>
{String(details?.status ?? '-')}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Hardware')}
</div>
<div className='mt-1 font-medium'>
{String(details?.brand_name ?? '')}{' '}
{String(details?.hardware_name ?? '')}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Total GPUs')}
</div>
<div className='mt-1 font-medium'>
{String(details?.total_gpus ?? details?.hardware_qty ?? '-')}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Containers')}
</div>
<div className='mt-1 font-medium'>{containers.length}</div>
</div>
</div>
{locations.length ? (
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Locations')}
</div>
<div className='mt-1 flex flex-wrap gap-2 text-sm'>
{locations.map((x) => (
<span key={x} className='bg-muted rounded-md px-2 py-1'>
{x}
</span>
))}
</div>
</div>
) : null}
{containers.length ? (
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground mb-2 text-xs'>
{t('Containers')}
</div>
<div className='space-y-2'>
{containers.map((c) => {
const id = c?.container_id
if (typeof id !== 'string' || !id) return null
const status =
typeof c?.status === 'string' ? c.status : undefined
const url =
typeof c?.public_url === 'string' ? c.public_url : ''
return (
<div
key={id}
className='flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2'
>
<div className='min-w-0'>
<div className='truncate font-mono text-sm'>{id}</div>
<div className='text-muted-foreground text-xs'>
{status ? `${t('Status')}: ${status}` : ''}
</div>
</div>
{url ? (
<Button
variant='outline'
size='sm'
onClick={() => window.open(url, '_blank')}
>
<ExternalLink className='mr-2 h-4 w-4' />
{t('Open')}
</Button>
) : null}
</div>
)
})}
</div>
</div>
) : null}
<Collapsible className='rounded-lg border p-3'>
<CollapsibleTrigger className='cursor-pointer text-sm font-medium'>
{t('Raw JSON')}
</CollapsibleTrigger>
<CollapsibleContent>
<pre className='mt-3 max-h-[360px] overflow-auto rounded-md bg-black p-3 text-xs text-gray-200'>
{payloadJson || '-'}
</pre>
</CollapsibleContent>
</Collapsible>
</>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -21,6 +21,12 @@ import { useQuery } from '@tanstack/react-query'
import { Download, Loader2, RefreshCcw, Terminal } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
@@ -30,7 +36,6 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Dialog } from '@/components/dialog'
import { getDeploymentLogs, listDeploymentContainers } from '../../api'
interface ViewLogsDialogProps {
@@ -137,180 +142,180 @@ export function ViewLogsDialog({
}
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
<Terminal className='h-5 w-5' />
{t('Deployment logs')}
</>
}
contentClassName='flex h-[calc(100dvh-2rem)] flex-col max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:h-[80vh] sm:max-w-4xl'
titleClassName='flex items-center gap-2'
contentHeight='min(72vh, 720px)'
bodyClassName='space-y-4'
>
<div className='mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}: {deploymentId}
</div>
<div className='grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center'>
<Button
variant='outline'
size='sm'
onClick={() => {
refetchContainers()
refetchLogs()
}}
disabled={isFetchingLogs || isFetchingContainers}
>
{isFetchingLogs || isFetchingContainers ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCcw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
<Button
variant='outline'
size='sm'
onClick={handleDownload}
disabled={!logsText.trim()}
>
<Download className='mr-2 h-4 w-4' />
{t('Download')}
</Button>
<div className='col-span-2 flex items-center justify-between gap-2 rounded-md border px-3 py-1.5 sm:col-span-1'>
<span className='text-xs'>{t('Auto refresh')}</span>
<Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='flex h-[calc(100dvh-2rem)] flex-col max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:h-[80vh] sm:max-w-4xl'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Terminal className='h-5 w-5' />
{t('Deployment logs')}
</DialogTitle>
</DialogHeader>
<div className='mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}: {deploymentId}
</div>
<div className='grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center'>
<Button
variant='outline'
size='sm'
onClick={() => {
refetchContainers()
refetchLogs()
}}
disabled={isFetchingLogs || isFetchingContainers}
>
{isFetchingLogs || isFetchingContainers ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCcw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
<Button
variant='outline'
size='sm'
onClick={handleDownload}
disabled={!logsText.trim()}
>
<Download className='mr-2 h-4 w-4' />
{t('Download')}
</Button>
<div className='col-span-2 flex items-center justify-between gap-2 rounded-md border px-3 py-1.5 sm:col-span-1'>
<span className='text-xs'>{t('Auto refresh')}</span>
<Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
</div>
</div>
</div>
</div>
<div className='mb-3 grid gap-2 sm:grid-cols-2 sm:gap-3'>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs'>{t('Container')}</div>
<Select
items={[
...containers.flatMap((c) => {
const id = c?.container_id
if (typeof id !== 'string' || !id) return []
const status =
typeof c?.status === 'string' && c.status
? ` (${c.status})`
: ''
return [
{
value: id,
label: (
<>
{id}
{status}
</>
),
},
]
}),
]}
value={containerId}
onValueChange={(v) => v !== null && setContainerId(v)}
disabled={isLoadingContainers || containers.length === 0}
>
<SelectTrigger>
<SelectValue
placeholder={
isLoadingContainers
? t('Loading...')
: containers.length === 0
? t('No containers')
: t('Select')
}
/>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{containers.map((c) => {
<div className='mb-3 grid gap-2 sm:grid-cols-2 sm:gap-3'>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs'>
{t('Container')}
</div>
<Select
items={[
...containers.flatMap((c) => {
const id = c?.container_id
if (typeof id !== 'string' || !id) return null
if (typeof id !== 'string' || !id) return []
const status =
typeof c?.status === 'string' && c.status
? ` (${c.status})`
: ''
return (
<SelectItem key={id} value={id}>
{id}
{status}
</SelectItem>
)
})}
</SelectGroup>
</SelectContent>
</Select>
return [
{
value: id,
label: (
<>
{id}
{status}
</>
),
},
]
}),
]}
value={containerId}
onValueChange={(v) => v !== null && setContainerId(v)}
disabled={isLoadingContainers || containers.length === 0}
>
<SelectTrigger>
<SelectValue
placeholder={
isLoadingContainers
? t('Loading...')
: containers.length === 0
? t('No containers')
: t('Select')
}
/>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{containers.map((c) => {
const id = c?.container_id
if (typeof id !== 'string' || !id) return null
const status =
typeof c?.status === 'string' && c.status
? ` (${c.status})`
: ''
return (
<SelectItem key={id} value={id}>
{id}
{status}
</SelectItem>
)
})}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs'>{t('Stream')}</div>
<Select
items={[
{ value: 'stdout', label: 'stdout' },
{ value: 'stderr', label: 'stderr' },
{ value: 'all', label: 'all' },
]}
value={stream}
onValueChange={(v) => {
if (v === 'stderr' || v === 'all' || v === 'stdout') {
setStream(v)
} else {
setStream('stdout')
}
}}
>
<SelectTrigger>
<SelectValue placeholder={t('Select')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='stdout'>stdout</SelectItem>
<SelectItem value='stderr'>stderr</SelectItem>
<SelectItem value='all'>all</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs'>{t('Stream')}</div>
<Select
items={[
{ value: 'stdout', label: 'stdout' },
{ value: 'stderr', label: 'stderr' },
{ value: 'all', label: 'all' },
]}
value={stream}
onValueChange={(v) => {
if (v === 'stderr' || v === 'all' || v === 'stdout') {
setStream(v)
} else {
setStream('stdout')
}
}}
>
<SelectTrigger>
<SelectValue placeholder={t('Select')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='stdout'>stdout</SelectItem>
<SelectItem value='stderr'>stderr</SelectItem>
<SelectItem value='all'>all</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<div
ref={scrollRef}
className='flex-1 overflow-auto rounded-md border bg-black p-3 sm:p-4'
onScroll={(e) => {
const target = e.target as HTMLDivElement
const isAtBottom =
target.scrollHeight - target.scrollTop - target.clientHeight < 50
setAutoScroll(isAtBottom)
}}
>
{isLoadingContainers || isLoadingLogs ? (
<div className='flex items-center justify-center py-8'>
<Loader2 className='h-6 w-6 animate-spin text-gray-400' />
</div>
) : containers.length === 0 ? (
<div className='py-8 text-center text-gray-400'>
{t('No containers')}
</div>
) : !containerId ? (
<div className='py-8 text-center text-gray-400'>
{t('Please select a container')}
</div>
) : !logsText.trim() ? (
<div className='py-8 text-center text-gray-400'>{t('No logs')}</div>
) : (
<div className='font-mono text-sm'>
{logLines.map((line, idx) => (
<div key={idx} className='whitespace-pre-wrap text-gray-200'>
{line}
</div>
))}
</div>
)}
</div>
</div>
<div
ref={scrollRef}
className='flex-1 overflow-auto rounded-md border bg-black p-3 sm:p-4'
onScroll={(e) => {
const target = e.target as HTMLDivElement
const isAtBottom =
target.scrollHeight - target.scrollTop - target.clientHeight < 50
setAutoScroll(isAtBottom)
}}
>
{isLoadingContainers || isLoadingLogs ? (
<div className='flex items-center justify-center py-8'>
<Loader2 className='h-6 w-6 animate-spin text-gray-400' />
</div>
) : containers.length === 0 ? (
<div className='py-8 text-center text-gray-400'>
{t('No containers')}
</div>
) : !containerId ? (
<div className='py-8 text-center text-gray-400'>
{t('Please select a container')}
</div>
) : !logsText.trim() ? (
<div className='py-8 text-center text-gray-400'>{t('No logs')}</div>
) : (
<div className='font-mono text-sm'>
{logLines.map((line, idx) => (
<div key={idx} className='whitespace-pre-wrap text-gray-200'>
{line}
</div>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}
@@ -196,7 +196,6 @@ export function ModelMutateDrawer({
'grok.violation_deduction_amount': 0,
'channel_affinity_setting.enabled': false,
'channel_affinity_setting.switch_on_success': true,
'channel_affinity_setting.keep_on_channel_disabled': false,
'channel_affinity_setting.max_entries': 100000,
'channel_affinity_setting.default_ttl_seconds': 3600,
'channel_affinity_setting.rules': '[]',
@@ -32,6 +32,12 @@ import { formatQuotaWithCurrency } from '@/lib/currency'
import dayjs from '@/lib/dayjs'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton'
import {
Tooltip,
@@ -39,7 +45,6 @@ import {
TooltipTrigger,
TooltipProvider,
} from '@/components/ui/tooltip'
import { Dialog } from '@/components/dialog'
import { Turnstile } from '@/components/turnstile'
import { getCheckinStatus, performCheckin } from '../api'
import type { CheckinRecord } from '../types'
@@ -248,26 +253,27 @@ export function CheckinCalendarCard({
setTurnstileWidgetKey((v) => v + 1)
}
}}
title={t('Security Check')}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='space-y-4'
>
<div className='text-muted-foreground text-sm'>
{t('Please complete the security check to continue.')}
</div>
<div className='flex justify-center py-4'>
<Turnstile
key={turnstileWidgetKey}
siteKey={turnstileSiteKey}
onVerify={(token) => {
doCheckin(token)
}}
onExpire={() => {
setTurnstileWidgetKey((v) => v + 1)
}}
/>
</div>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Security Check')}</DialogTitle>
</DialogHeader>
<div className='text-muted-foreground text-sm'>
{t('Please complete the security check to continue.')}
</div>
<div className='flex justify-center py-4'>
<Turnstile
key={turnstileWidgetKey}
siteKey={turnstileSiteKey}
onVerify={(token) => {
doCheckin(token)
}}
onExpire={() => {
setTurnstileWidgetKey((v) => v + 1)
}}
/>
</div>
</DialogContent>
</Dialog>
<div className='bg-card overflow-hidden rounded-2xl border'>
@@ -20,10 +20,17 @@ import { useEffect } from 'react'
import { RefreshCw, Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { CopyButton } from '@/components/copy-button'
import { Dialog } from '@/components/dialog'
import { useAccessToken } from '../../hooks'
// ============================================================================
@@ -50,18 +57,45 @@ export function AccessTokenDialog({
}, [open, token, generate])
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Access Token')}
description={t(
"Your system access token for API authentication. Keep it secure and don't share it with others."
)}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Access Token')}</DialogTitle>
<DialogDescription>
{t(
"Your system access token for API authentication. Keep it secure and don't share it with others."
)}
</DialogDescription>
</DialogHeader>
<div className='my-6 space-y-4'>
<div className='space-y-2'>
<Label htmlFor='token'>{t('Token')}</Label>
<div className='flex gap-2'>
<Input
id='token'
type='text'
value={token}
readOnly
className='font-mono text-xs'
placeholder={t('Click "Generate" to create a token')}
/>
<CopyButton
value={token}
variant='outline'
className='size-9'
iconClassName='size-4'
tooltip={t('Copy token')}
aria-label={t('Copy token')}
/>
</div>
<p className='text-muted-foreground text-xs'>
{t('Use this token for API authentication')}
</p>
</div>
</div>
<DialogFooter>
<Button
type='button'
variant='outline'
@@ -82,35 +116,8 @@ export function AccessTokenDialog({
)}
{generating ? t('Generating...') : t('Regenerate')}
</Button>
</>
}
>
<div className='my-6 space-y-4'>
<div className='space-y-2'>
<Label htmlFor='token'>{t('Token')}</Label>
<div className='flex gap-2'>
<Input
id='token'
type='text'
value={token}
readOnly
className='font-mono text-xs'
placeholder={t('Click "Generate" to create a token')}
/>
<CopyButton
value={token}
variant='outline'
className='size-9'
iconClassName='size-4'
tooltip={t('Copy token')}
aria-label={t('Copy token')}
/>
</div>
<p className='text-muted-foreground text-xs'>
{t('Use this token for API authentication')}
</p>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -21,8 +21,15 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { PasswordInput } from '@/components/password-input'
import { updateUserProfile } from '../../api'
@@ -107,79 +114,82 @@ export function ChangePasswordDialog({
}
}
const formId = 'change-password-form'
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Change Password')}
description={
<>
{t('Update your password for account:')} <strong>{username}</strong>
</>
}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
disabled={loading}
>
{t('Cancel')}
</Button>
<Button type='submit' form={formId} disabled={loading}>
{loading && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{loading ? t('Changing...') : t('Change Password')}
</Button>
</>
}
>
<form id={formId} onSubmit={handleSubmit} className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='currentPassword'>{t('Current Password')}</Label>
<PasswordInput
id='currentPassword'
value={formData.originalPassword}
onChange={(e) => handleChange('originalPassword', e.target.value)}
disabled={loading}
required
autoComplete='current-password'
/>
</div>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-md'>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{t('Change Password')}</DialogTitle>
<DialogDescription>
{t('Update your password for account:')}{' '}
<strong>{username}</strong>
</DialogDescription>
</DialogHeader>
<div className='space-y-2'>
<Label htmlFor='newPassword'>{t('New Password')}</Label>
<PasswordInput
id='newPassword'
value={formData.newPassword}
onChange={(e) => handleChange('newPassword', e.target.value)}
disabled={loading}
required
minLength={8}
autoComplete='new-password'
/>
<p className='text-muted-foreground text-xs'>
{t('Must be at least 8 characters')}
</p>
</div>
<div className='my-6 space-y-4'>
<div className='space-y-2'>
<Label htmlFor='currentPassword'>{t('Current Password')}</Label>
<PasswordInput
id='currentPassword'
value={formData.originalPassword}
onChange={(e) =>
handleChange('originalPassword', e.target.value)
}
disabled={loading}
required
autoComplete='current-password'
/>
</div>
<div className='space-y-2'>
<Label htmlFor='confirmPassword'>{t('Confirm New Password')}</Label>
<PasswordInput
id='confirmPassword'
value={formData.confirmPassword}
onChange={(e) => handleChange('confirmPassword', e.target.value)}
disabled={loading}
required
autoComplete='new-password'
/>
</div>
</form>
<div className='space-y-2'>
<Label htmlFor='newPassword'>{t('New Password')}</Label>
<PasswordInput
id='newPassword'
value={formData.newPassword}
onChange={(e) => handleChange('newPassword', e.target.value)}
disabled={loading}
required
minLength={8}
autoComplete='new-password'
/>
<p className='text-muted-foreground text-xs'>
{t('Must be at least 8 characters')}
</p>
</div>
<div className='space-y-2'>
<Label htmlFor='confirmPassword'>
{t('Confirm New Password')}
</Label>
<PasswordInput
id='confirmPassword'
value={formData.confirmPassword}
onChange={(e) =>
handleChange('confirmPassword', e.target.value)
}
disabled={loading}
required
autoComplete='new-password'
/>
</div>
</div>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
disabled={loading}
>
{t('Cancel')}
</Button>
<Button type='submit' disabled={loading}>
{loading && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{loading ? t('Changing...') : t('Change Password')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -25,9 +25,16 @@ import { useAuthStore } from '@/stores/auth-store'
import { api } from '@/lib/api'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { deleteUserAccount } from '../../api'
// ============================================================================
@@ -94,24 +101,45 @@ export function DeleteAccountDialog({
}
return (
<Dialog
open={open}
onOpenChange={handleOpenChange}
title={
<>
<AlertTriangle className='h-5 w-5' />
{t('Delete Account')}
</>
}
description={t(
'This action cannot be undone. This will permanently delete your account and remove all your data from our servers.'
)}
contentClassName='sm:max-w-md'
titleClassName='text-destructive flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle className='text-destructive flex items-center gap-2'>
<AlertTriangle className='h-5 w-5' />
{t('Delete Account')}
</DialogTitle>
<DialogDescription>
{t(
'This action cannot be undone. This will permanently delete your account and remove all your data from our servers.'
)}
</DialogDescription>
</DialogHeader>
<div className='my-6 space-y-4'>
<Alert variant='destructive'>
<AlertTriangle className='h-4 w-4' />
<AlertDescription>
{t('Warning: This action is permanent and irreversible!')}
</AlertDescription>
</Alert>
<div className='space-y-2'>
<Label htmlFor='confirmation'>
{t('Type')} <strong>{username}</strong> {t('to confirm')}
</Label>
<Input
id='confirmation'
type='text'
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
disabled={loading}
placeholder={username}
autoComplete='off'
/>
</div>
</div>
<DialogFooter>
<Button
type='button'
variant='outline'
@@ -129,32 +157,8 @@ export function DeleteAccountDialog({
{loading && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{loading ? t('Deleting...') : t('Delete Account')}
</Button>
</>
}
>
<div className='my-6 space-y-4'>
<Alert variant='destructive'>
<AlertTriangle className='h-4 w-4' />
<AlertDescription>
{t('Warning: This action is permanent and irreversible!')}
</AlertDescription>
</Alert>
<div className='space-y-2'>
<Label htmlFor='confirmation'>
{t('Type')} <strong>{username}</strong> {t('to confirm')}
</Label>
<Input
id='confirmation'
type='text'
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
disabled={loading}
placeholder={username}
autoComplete='off'
/>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -22,9 +22,16 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useCountdown } from '@/hooks/use-countdown'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { sendEmailVerification, bindEmail } from '../../api'
// ============================================================================
@@ -122,22 +129,60 @@ export function EmailBindDialog({
}
return (
<Dialog
open={open}
onOpenChange={handleOpenChange}
title={t('Bind Email')}
description={
currentEmail
? t('Current email: {{email}}. Enter a new email to change.', {
email: currentEmail,
})
: t('Bind an email address to your account.')
}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Bind Email')}</DialogTitle>
<DialogDescription>
{currentEmail
? t('Current email: {{email}}. Enter a new email to change.', {
email: currentEmail,
})
: t('Bind an email address to your account.')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label htmlFor='email'>{t('Email Address')}</Label>
<Input
id='email'
type='email'
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('Enter your email')}
disabled={loading}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='code'>{t('Verification Code')}</Label>
<div className='flex gap-2'>
<Input
id='code'
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter code')}
disabled={loading}
maxLength={6}
/>
<Button
type='button'
variant='outline'
onClick={handleSendCode}
disabled={sendingCode || isActive || !email}
>
{isActive
? `${secondsLeft}s`
: sendingCode
? t('Sending...')
: t('Send')}
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button
type='button'
variant='outline'
@@ -154,48 +199,8 @@ export function EmailBindDialog({
{loading && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{loading ? t('Binding...') : t('Bind Email')}
</Button>
</>
}
>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label htmlFor='email'>{t('Email Address')}</Label>
<Input
id='email'
type='email'
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('Enter your email')}
disabled={loading}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='code'>{t('Verification Code')}</Label>
<div className='flex gap-2'>
<Input
id='code'
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter code')}
disabled={loading}
maxLength={6}
/>
<Button
type='button'
variant='outline'
onClick={handleSendCode}
disabled={sendingCode || isActive || !email}
>
{isActive
? `${secondsLeft}s`
: sendingCode
? t('Sending...')
: t('Send')}
</Button>
</div>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -19,7 +19,13 @@ For commercial licensing, please contact support@quantumnous.com
import { Send } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Dialog } from '@/components/dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
// ============================================================================
// Telegram Bind Dialog Component
@@ -39,55 +45,56 @@ export function TelegramBindDialog({
}: TelegramBindDialogProps) {
const { t } = useTranslation()
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Bind Telegram Account')}
description={t('Click the button below to bind your Telegram account')}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='space-y-4'
>
<div className='space-y-4 py-4'>
<Alert>
<Send className='h-4 w-4' />
<AlertDescription>
{t(
'You will be redirected to Telegram to complete the binding process.'
)}
</AlertDescription>
</Alert>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Bind Telegram Account')}</DialogTitle>
<DialogDescription>
{t('Click the button below to bind your Telegram account')}
</DialogDescription>
</DialogHeader>
<div className='flex flex-col items-center justify-center gap-4 rounded-lg border p-6'>
<div className='flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100 dark:bg-blue-900'>
<Send className='h-6 w-6 text-blue-600 dark:text-blue-400' />
</div>
<div className='text-center'>
<p className='text-muted-foreground text-sm'>
{t('Bot:')}{' '}
<span className='font-mono font-semibold'>@{botName}</span>
</p>
<p className='text-muted-foreground mt-1 text-xs'>
<div className='space-y-4 py-4'>
<Alert>
<Send className='h-4 w-4' />
<AlertDescription>
{t(
"After clicking the button, you'll be asked to authorize the bot"
'You will be redirected to Telegram to complete the binding process.'
)}
</p>
</div>
</AlertDescription>
</Alert>
{/* Telegram Login Widget will be injected here by react-telegram-login */}
<div id='telegram-login-widget' className='flex justify-center'>
{/* This would require the react-telegram-login library */}
<div className='text-muted-foreground rounded-lg border border-dashed px-6 py-3 text-sm'>
{t('Telegram Login Widget')}
<div className='flex flex-col items-center justify-center gap-4 rounded-lg border p-6'>
<div className='flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100 dark:bg-blue-900'>
<Send className='h-6 w-6 text-blue-600 dark:text-blue-400' />
</div>
<div className='text-center'>
<p className='text-muted-foreground text-sm'>
{t('Bot:')}{' '}
<span className='font-mono font-semibold'>@{botName}</span>
</p>
<p className='text-muted-foreground mt-1 text-xs'>
{t(
"After clicking the button, you'll be asked to authorize the bot"
)}
</p>
</div>
{/* Telegram Login Widget will be injected here by react-telegram-login */}
<div id='telegram-login-widget' className='flex justify-center'>
{/* This would require the react-telegram-login library */}
<div className='text-muted-foreground rounded-lg border border-dashed px-6 py-3 text-sm'>
{t('Telegram Login Widget')}
</div>
</div>
</div>
</div>
<p className='text-muted-foreground text-center text-xs'>
{t('The binding will complete automatically after authorization')}
</p>
</div>
<p className='text-muted-foreground text-center text-xs'>
{t('The binding will complete automatically after authorization')}
</p>
</div>
</DialogContent>
</Dialog>
)
}
@@ -23,10 +23,17 @@ import { toast } from 'sonner'
import { regenerate2FABackupCodes } from '@/lib/api'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { CopyButton } from '@/components/copy-button'
import { Dialog } from '@/components/dialog'
// ============================================================================
// Two-FA Backup Codes Dialog Component
@@ -87,26 +94,82 @@ export function TwoFABackupDialog({
}
return (
<Dialog
open={open}
onOpenChange={handleOpenChange}
title={
<>
<RefreshCw className='h-5 w-5' />
{t('Regenerate Backup Codes')}
</>
}
description={
backupCodes.length > 0
? t('Your new backup codes are ready')
: t('Generate new backup codes for account recovery')
}
contentClassName='sm:max-w-md'
titleClassName='flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<RefreshCw className='h-5 w-5' />
{t('Regenerate Backup Codes')}
</DialogTitle>
<DialogDescription>
{backupCodes.length > 0
? t('Your new backup codes are ready')
: t('Generate new backup codes for account recovery')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
{backupCodes.length === 0 ? (
<>
<Alert>
<AlertDescription>
{t(
'Generating new codes will invalidate all existing backup codes.'
)}
</AlertDescription>
</Alert>
<div className='space-y-2'>
<Label htmlFor='code'>{t('Verification Code')}</Label>
<Input
id='code'
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter authenticator code')}
maxLength={6}
disabled={loading}
/>
</div>
</>
) : (
<>
<Alert>
<AlertDescription>
{t(
'Save these codes in a safe place. Each code can only be used once.'
)}
</AlertDescription>
</Alert>
<div className='rounded-lg border p-4'>
<div className='grid grid-cols-2 gap-2'>
{backupCodes.map((code, index) => (
<div
key={index}
className='bg-muted rounded-md p-2 text-center font-mono text-sm'
>
{code}
</div>
))}
</div>
</div>
<CopyButton
value={backupCodes.join('\n')}
variant='outline'
size='default'
className='w-full'
iconClassName='mr-2 size-4'
tooltip={t('Copy all backup codes')}
aria-label={t('Copy all backup codes')}
>
{t('Copy All Codes')}
</CopyButton>
</>
)}
</div>
<DialogFooter>
{backupCodes.length === 0 ? (
<>
<Button
@@ -124,69 +187,8 @@ export function TwoFABackupDialog({
) : (
<Button onClick={handleDone}>{t('Done')}</Button>
)}
</>
}
>
<div className='space-y-4 py-4'>
{backupCodes.length === 0 ? (
<>
<Alert>
<AlertDescription>
{t(
'Generating new codes will invalidate all existing backup codes.'
)}
</AlertDescription>
</Alert>
<div className='space-y-2'>
<Label htmlFor='code'>{t('Verification Code')}</Label>
<Input
id='code'
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter authenticator code')}
maxLength={6}
disabled={loading}
/>
</div>
</>
) : (
<>
<Alert>
<AlertDescription>
{t(
'Save these codes in a safe place. Each code can only be used once.'
)}
</AlertDescription>
</Alert>
<div className='rounded-lg border p-4'>
<div className='grid grid-cols-2 gap-2'>
{backupCodes.map((code, index) => (
<div
key={index}
className='bg-muted rounded-md p-2 text-center font-mono text-sm'
>
{code}
</div>
))}
</div>
</div>
<CopyButton
value={backupCodes.join('\n')}
variant='outline'
size='default'
className='w-full'
iconClassName='mr-2 size-4'
tooltip={t('Copy all backup codes')}
aria-label={t('Copy all backup codes')}
>
{t('Copy All Codes')}
</CopyButton>
</>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -24,9 +24,16 @@ import { disable2FA } from '@/lib/api'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
// ============================================================================
// Two-FA Disable Dialog Component
@@ -91,24 +98,60 @@ export function TwoFADisableDialog({
}
return (
<Dialog
open={open}
onOpenChange={handleOpenChange}
title={
<>
<AlertTriangle className='h-5 w-5' />
{t('Disable Two-Factor Authentication')}
</>
}
description={t(
'This action will permanently remove 2FA protection from your account.'
)}
contentClassName='sm:max-w-md'
titleClassName='text-destructive flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle className='text-destructive flex items-center gap-2'>
<AlertTriangle className='h-5 w-5' />
{t('Disable Two-Factor Authentication')}
</DialogTitle>
<DialogDescription>
{t(
'This action will permanently remove 2FA protection from your account.'
)}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<Alert variant='destructive'>
<AlertTriangle className='h-4 w-4' />
<AlertDescription>
{t('Warning: Disabling 2FA will make your account less secure.')}
</AlertDescription>
</Alert>
<div className='space-y-2'>
<Label htmlFor='code'>{t('Verification Code')}</Label>
<Input
id='code'
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter code or backup code')}
disabled={loading}
/>
<p className='text-muted-foreground text-xs'>
{t('Enter your authenticator code or a backup code')}
</p>
</div>
<div className='flex items-start space-x-2'>
<Checkbox
id='confirm'
checked={confirmed}
onCheckedChange={(checked) => setConfirmed(checked as boolean)}
/>
<Label
htmlFor='confirm'
className='text-sm leading-tight font-normal'
>
{t(
'I understand that disabling 2FA will remove all protection and backup codes'
)}
</Label>
</div>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={() => handleOpenChange(false)}
@@ -124,47 +167,8 @@ export function TwoFADisableDialog({
{loading && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{loading ? t('Disabling...') : t('Disable 2FA')}
</Button>
</>
}
>
<div className='space-y-4 py-4'>
<Alert variant='destructive'>
<AlertTriangle className='h-4 w-4' />
<AlertDescription>
{t('Warning: Disabling 2FA will make your account less secure.')}
</AlertDescription>
</Alert>
<div className='space-y-2'>
<Label htmlFor='code'>{t('Verification Code')}</Label>
<Input
id='code'
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter code or backup code')}
disabled={loading}
/>
<p className='text-muted-foreground text-xs'>
{t('Enter your authenticator code or a backup code')}
</p>
</div>
<div className='flex items-start space-x-2'>
<Checkbox
id='confirm'
checked={confirmed}
onCheckedChange={(checked) => setConfirmed(checked as boolean)}
/>
<Label
htmlFor='confirm'
className='text-sm leading-tight font-normal'
>
{t(
'I understand that disabling 2FA will remove all protection and backup codes'
)}
</Label>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -24,10 +24,17 @@ import { toast } from 'sonner'
import { setup2FA, enable2FA } from '@/lib/api'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { CopyButton } from '@/components/copy-button'
import { Dialog } from '@/components/dialog'
import type { TwoFASetupData } from '../../types'
// ============================================================================
@@ -129,23 +136,123 @@ export function TwoFASetupDialog({
}, [open, setupData, initializing, handleSetup])
return (
<Dialog
open={open}
onOpenChange={handleOpenChange}
title={t('Setup Two-Factor Authentication')}
description={
<>
{t('Step')}
{step + 1}
{t('of 3:')}
{stepLabels[step]}
</>
}
contentClassName='sm:max-w-lg'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Setup Two-Factor Authentication')}</DialogTitle>
<DialogDescription>
{t('Step')} {step + 1} {t('of 3:')} {stepLabels[step]}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
{initializing ? (
<div className='flex flex-col items-center justify-center gap-3 py-8'>
<div className='border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent' />
<div className='text-muted-foreground text-sm'>
{t('Setting up 2FA...')}
</div>
</div>
) : !setupData ? (
<div className='flex justify-center py-8'>
<div className='text-muted-foreground'>
{t('Failed to load setup data')}
</div>
</div>
) : (
<>
{/* Step 0: QR Code */}
{step === 0 && (
<div className='space-y-4'>
<p className='text-muted-foreground text-sm'>
{t(
'Scan this QR code with your authenticator app (Google Authenticator, Microsoft Authenticator, etc.)'
)}
</p>
<div className='flex justify-center rounded-lg bg-white p-4'>
<QRCodeSVG value={setupData.qr_code_data} size={200} />
</div>
<div className='bg-muted rounded-lg p-3'>
<div className='flex items-center justify-between'>
<div>
<p className='text-muted-foreground text-xs'>
{t('Or enter this key manually:')}
</p>
<code className='font-mono text-sm'>
{setupData.secret}
</code>
</div>
<CopyButton
value={setupData.secret}
variant='ghost'
tooltip={t('Copy secret key')}
aria-label={t('Copy secret key')}
/>
</div>
</div>
</div>
)}
{/* Step 1: Backup Codes */}
{step === 1 && (
<div className='space-y-4'>
<Alert>
<AlertDescription>
{t(
'Save these backup codes in a safe place. Each code can only be used once.'
)}
</AlertDescription>
</Alert>
<div className='rounded-lg border p-4'>
<div className='grid grid-cols-2 gap-2'>
{setupData.backup_codes.map((code, index) => (
<div
key={index}
className='bg-muted rounded-md p-2 text-center font-mono text-sm'
>
{code}
</div>
))}
</div>
</div>
<CopyButton
value={setupData.backup_codes.join('\n')}
variant='outline'
size='default'
className='w-full'
iconClassName='mr-2 size-4'
tooltip={t('Copy all backup codes')}
aria-label={t('Copy all backup codes')}
>
{t('Copy All Codes')}
</CopyButton>
</div>
)}
{/* Step 2: Verify */}
{step === 2 && (
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='code'>{t('Verification Code')}</Label>
<Input
id='code'
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter 6-digit code')}
maxLength={6}
disabled={loading}
/>
<p className='text-muted-foreground text-xs'>
{t('Enter the 6-digit code from your authenticator app')}
</p>
</div>
</div>
)}
</>
)}
</div>
<DialogFooter>
{step > 0 && (
<Button
variant='outline'
@@ -171,115 +278,8 @@ export function TwoFASetupDialog({
{loading ? t('Enabling...') : t('Enable 2FA')}
</Button>
)}
</>
}
>
<div className='space-y-4 py-4'>
{initializing ? (
<div className='flex flex-col items-center justify-center gap-3 py-8'>
<div className='border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent' />
<div className='text-muted-foreground text-sm'>
{t('Setting up 2FA...')}
</div>
</div>
) : !setupData ? (
<div className='flex justify-center py-8'>
<div className='text-muted-foreground'>
{t('Failed to load setup data')}
</div>
</div>
) : (
<>
{/* Step 0: QR Code */}
{step === 0 && (
<div className='space-y-4'>
<p className='text-muted-foreground text-sm'>
{t(
'Scan this QR code with your authenticator app (Google Authenticator, Microsoft Authenticator, etc.)'
)}
</p>
<div className='flex justify-center rounded-lg bg-white p-4'>
<QRCodeSVG value={setupData.qr_code_data} size={200} />
</div>
<div className='bg-muted rounded-lg p-3'>
<div className='flex items-center justify-between'>
<div>
<p className='text-muted-foreground text-xs'>
{t('Or enter this key manually:')}
</p>
<code className='font-mono text-sm'>
{setupData.secret}
</code>
</div>
<CopyButton
value={setupData.secret}
variant='ghost'
tooltip={t('Copy secret key')}
aria-label={t('Copy secret key')}
/>
</div>
</div>
</div>
)}
{/* Step 1: Backup Codes */}
{step === 1 && (
<div className='space-y-4'>
<Alert>
<AlertDescription>
{t(
'Save these backup codes in a safe place. Each code can only be used once.'
)}
</AlertDescription>
</Alert>
<div className='rounded-lg border p-4'>
<div className='grid grid-cols-2 gap-2'>
{setupData.backup_codes.map((code, index) => (
<div
key={index}
className='bg-muted rounded-md p-2 text-center font-mono text-sm'
>
{code}
</div>
))}
</div>
</div>
<CopyButton
value={setupData.backup_codes.join('\n')}
variant='outline'
size='default'
className='w-full'
iconClassName='mr-2 size-4'
tooltip={t('Copy all backup codes')}
aria-label={t('Copy all backup codes')}
>
{t('Copy All Codes')}
</CopyButton>
</div>
)}
{/* Step 2: Verify */}
{step === 2 && (
<div className='space-y-4'>
<div className='space-y-2'>
<Label htmlFor='code'>{t('Verification Code')}</Label>
<Input
id='code'
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter 6-digit code')}
maxLength={6}
disabled={loading}
/>
<p className='text-muted-foreground text-xs'>
{t('Enter the 6-digit code from your authenticator app')}
</p>
</div>
</div>
)}
</>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -19,7 +19,13 @@ For commercial licensing, please contact support@quantumnous.com
import { QrCode } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Dialog } from '@/components/dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
// ============================================================================
// WeChat Bind Dialog Component
@@ -37,39 +43,40 @@ export function WeChatBindDialog({
}: WeChatBindDialogProps) {
const { t } = useTranslation()
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Bind WeChat Account')}
description={t('Scan the QR code with WeChat to bind your account')}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='space-y-4'
>
<div className='space-y-4 py-4'>
<Alert>
<QrCode className='h-4 w-4' />
<AlertDescription>
{t(
'Please use WeChat\'s "Scan QR Code" feature to complete the binding process.'
)}
</AlertDescription>
</Alert>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{t('Bind WeChat Account')}</DialogTitle>
<DialogDescription>
{t('Scan the QR code with WeChat to bind your account')}
</DialogDescription>
</DialogHeader>
<div className='flex flex-col items-center justify-center rounded-lg border border-dashed p-8'>
<QrCode className='text-muted-foreground mb-3 h-16 w-16' />
<p className='text-muted-foreground text-sm'>
{t('WeChat QR code will be displayed here')}
</p>
<p className='text-muted-foreground mt-2 text-xs'>
{t('This feature requires server-side WeChat configuration')}
<div className='space-y-4 py-4'>
<Alert>
<QrCode className='h-4 w-4' />
<AlertDescription>
{t(
'Please use WeChat\'s "Scan QR Code" feature to complete the binding process.'
)}
</AlertDescription>
</Alert>
<div className='flex flex-col items-center justify-center rounded-lg border border-dashed p-8'>
<QrCode className='text-muted-foreground mb-3 h-16 w-16' />
<p className='text-muted-foreground text-sm'>
{t('WeChat QR code will be displayed here')}
</p>
<p className='text-muted-foreground mt-2 text-xs'>
{t('This feature requires server-side WeChat configuration')}
</p>
</div>
<p className='text-muted-foreground text-center text-xs'>
{t('After scanning, the binding will complete automatically')}
</p>
</div>
<p className='text-muted-foreground text-center text-xs'>
{t('After scanning, the binding will complete automatically')}
</p>
</div>
</DialogContent>
</Dialog>
)
}
@@ -118,11 +118,6 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
variant='neutral'
copyable={false}
/>
<StatusBadge
label={`${t('User ID')} ${profile.id}`}
variant='info'
copyText={String(profile.id)}
/>
</div>
<div className='text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs sm:gap-x-4 sm:text-sm'>
@@ -25,6 +25,12 @@ import { formatQuota } from '@/lib/format'
import { useSystemConfig } from '@/hooks/use-system-config'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
@@ -34,7 +40,6 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Dialog } from '@/components/dialog'
import { GroupBadge } from '@/components/group-badge'
import {
paySubscriptionStripe,
@@ -254,189 +259,189 @@ export function SubscriptionPurchaseDialog(props: Props) {
}
return (
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={
<>
<Crown className='h-5 w-5' />
{t('Purchase Subscription')}
</>
}
contentClassName='max-sm:w-[calc(100vw-1.5rem)] sm:max-w-md'
titleClassName='flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
>
<div className='space-y-3 sm:space-y-4'>
<div className='bg-muted/50 space-y-2.5 rounded-lg border p-3 sm:space-y-3 sm:p-4'>
<div className='flex justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Plan Name')}
</span>
<span className='max-w-[200px] truncate text-sm font-medium'>
{plan.title}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Validity Period')}
</span>
<span className='flex items-center gap-1 text-sm'>
<CalendarClock className='h-3.5 w-3.5' />
{formatDuration(plan, t)}
</span>
</div>
{formatResetPeriod(plan, t) !== t('No Reset') && (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='max-sm:w-[calc(100vw-1.5rem)] sm:max-w-md'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Crown className='h-5 w-5' />
{t('Purchase Subscription')}
</DialogTitle>
</DialogHeader>
<div className='space-y-3 sm:space-y-4'>
<div className='bg-muted/50 space-y-2.5 rounded-lg border p-3 sm:space-y-3 sm:p-4'>
<div className='flex justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Reset Period')}
{t('Plan Name')}
</span>
<span className='max-w-[200px] truncate text-sm font-medium'>
{plan.title}
</span>
<span className='text-sm'>{formatResetPeriod(plan, t)}</span>
</div>
)}
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Received amount')}
</span>
<span className='flex items-center gap-1 text-sm'>
<Package className='h-3.5 w-3.5' />
{totalAmount > 0 ? formatQuota(totalAmount) : t('Unlimited')}
</span>
</div>
{plan.upgrade_group && (
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Upgrade Group')}
{t('Validity Period')}
</span>
<span className='flex items-center gap-1 text-sm'>
<CalendarClock className='h-3.5 w-3.5' />
{formatDuration(plan, t)}
</span>
<GroupBadge group={plan.upgrade_group} />
</div>
)}
<Separator />
<div className='flex items-center justify-between'>
<span className='text-sm font-medium'>{t('Amount Due')}</span>
<span className='text-primary text-lg font-bold'>${price}</span>
{formatResetPeriod(plan, t) !== t('No Reset') && (
<div className='flex justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Reset Period')}
</span>
<span className='text-sm'>{formatResetPeriod(plan, t)}</span>
</div>
)}
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Received amount')}
</span>
<span className='flex items-center gap-1 text-sm'>
<Package className='h-3.5 w-3.5' />
{totalAmount > 0 ? formatQuota(totalAmount) : t('Unlimited')}
</span>
</div>
{plan.upgrade_group && (
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>
{t('Upgrade Group')}
</span>
<GroupBadge group={plan.upgrade_group} />
</div>
)}
<Separator />
<div className='flex items-center justify-between'>
<span className='text-sm font-medium'>{t('Amount Due')}</span>
<span className='text-primary text-lg font-bold'>${price}</span>
</div>
</div>
</div>
{limitReached && (
<Alert variant='destructive'>
<AlertDescription>
{t('Purchase limit reached')} ({props.purchaseCount}/
{props.purchaseLimit})
</AlertDescription>
</Alert>
)}
<div className='flex flex-col gap-2 rounded-md border p-3'>
<div className='flex items-center justify-between gap-2 text-xs'>
<span className='text-muted-foreground'>{t('Required')}</span>
<span>{formatQuota(balanceCost)}</span>
</div>
<div className='flex items-center justify-between gap-2 text-xs'>
<span className='text-muted-foreground'>{t('Available')}</span>
<span>{formatQuota(userQuota)}</span>
</div>
{!allowBalancePay ? (
{limitReached && (
<Alert variant='destructive'>
<AlertDescription>
{t('This plan does not allow balance redemption')}
{t('Purchase limit reached')} ({props.purchaseCount}/
{props.purchaseLimit})
</AlertDescription>
</Alert>
) : (
insufficientBalance && (
<Alert variant='destructive'>
<AlertDescription>{t('Insufficient balance')}</AlertDescription>
</Alert>
)
)}
<Button
variant='outline'
onClick={handlePayBalance}
disabled={
paying || limitReached || !allowBalancePay || insufficientBalance
}
>
{t('Pay with Balance')}
</Button>
</div>
{hasAnyPayment && (
<div className='space-y-3'>
<p className='text-muted-foreground text-xs'>
{t('Select payment method')}
</p>
{(hasStripe || hasCreem || hasWaffoPancake) && (
<div className='grid grid-cols-2 gap-2 sm:flex'>
{hasStripe && (
<Button
variant='outline'
className='flex-1'
onClick={handlePayStripe}
disabled={paying || limitReached}
>
Stripe
</Button>
)}
{hasCreem && (
<Button
variant='outline'
className='flex-1'
onClick={handlePayCreem}
disabled={paying || limitReached}
>
Creem
</Button>
)}
{hasWaffoPancake && (
<Button
variant='outline'
className='flex-1'
onClick={handlePayWaffoPancake}
disabled={paying || limitReached}
>
Waffo Pancake
</Button>
)}
</div>
)}
{hasEpay && (
<div className='grid grid-cols-[minmax(0,1fr)_auto] gap-2'>
<Select
items={[
...(props.epayMethods || []).map((m) => ({
value: m.type,
label: m.name || m.type,
})),
]}
value={selectedEpayMethod}
onValueChange={(v) => v !== null && setSelectedEpayMethod(v)}
disabled={limitReached}
>
<SelectTrigger className='flex-1'>
<SelectValue>{selectedEpayMethodLabel}</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{(props.epayMethods || []).map((m) => (
<SelectItem key={m.type} value={m.type}>
{m.name || m.type}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
onClick={handlePayEpay}
disabled={paying || !selectedEpayMethod || limitReached}
>
{t('Pay')}
</Button>
</div>
<div className='flex flex-col gap-2 rounded-md border p-3'>
<div className='flex items-center justify-between gap-2 text-xs'>
<span className='text-muted-foreground'>{t('Required')}</span>
<span>{formatQuota(balanceCost)}</span>
</div>
<div className='flex items-center justify-between gap-2 text-xs'>
<span className='text-muted-foreground'>{t('Available')}</span>
<span>{formatQuota(userQuota)}</span>
</div>
{!allowBalancePay ? (
<Alert variant='destructive'>
<AlertDescription>
{t('This plan does not allow balance redemption')}
</AlertDescription>
</Alert>
) : (
insufficientBalance && (
<Alert variant='destructive'>
<AlertDescription>
{t('Insufficient balance')}
</AlertDescription>
</Alert>
)
)}
<Button
variant='outline'
onClick={handlePayBalance}
disabled={
paying || limitReached || !allowBalancePay || insufficientBalance
}
>
{t('Pay with Balance')}
</Button>
</div>
)}
</div>
{hasAnyPayment && (
<div className='space-y-3'>
<p className='text-muted-foreground text-xs'>
{t('Select payment method')}
</p>
{(hasStripe || hasCreem || hasWaffoPancake) && (
<div className='grid grid-cols-2 gap-2 sm:flex'>
{hasStripe && (
<Button
variant='outline'
className='flex-1'
onClick={handlePayStripe}
disabled={paying || limitReached}
>
Stripe
</Button>
)}
{hasCreem && (
<Button
variant='outline'
className='flex-1'
onClick={handlePayCreem}
disabled={paying || limitReached}
>
Creem
</Button>
)}
{hasWaffoPancake && (
<Button
variant='outline'
className='flex-1'
onClick={handlePayWaffoPancake}
disabled={paying || limitReached}
>
Waffo Pancake
</Button>
)}
</div>
)}
{hasEpay && (
<div className='grid grid-cols-[minmax(0,1fr)_auto] gap-2'>
<Select
items={[
...(props.epayMethods || []).map((m) => ({
value: m.type,
label: m.name || m.type,
})),
]}
value={selectedEpayMethod}
onValueChange={(v) =>
v !== null && setSelectedEpayMethod(v)
}
disabled={limitReached}
>
<SelectTrigger className='flex-1'>
<SelectValue>{selectedEpayMethodLabel}</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{(props.epayMethods || []).map((m) => (
<SelectItem key={m.type} value={m.type}>
{m.name || m.type}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
onClick={handlePayEpay}
disabled={paying || !selectedEpayMethod || limitReached}
>
{t('Pay')}
</Button>
</div>
)}
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}
@@ -21,6 +21,14 @@ import { type Resolver, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -42,7 +50,6 @@ import {
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import {
SettingsForm,
SettingsSwitchContent,
@@ -67,8 +74,6 @@ type ProviderFormDialogProps = {
provider?: CustomOAuthProvider | null
}
const PROVIDER_FORM_ID = 'custom-oauth-provider-form'
export function ProviderFormDialog(props: ProviderFormDialogProps) {
const { t } = useTranslation()
const isEditing = !!props.provider
@@ -169,97 +174,98 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
const isPending = createProvider.isPending || updateProvider.isPending
return (
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={isEditing ? t('Edit OAuth Provider') : t('Add OAuth Provider')}
description={
isEditing
? t('Update the configuration for this custom OAuth provider.')
: t('Configure a new custom OAuth provider for user authentication.')
}
contentClassName='max-h-[85vh] overflow-y-auto sm:max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => props.onOpenChange(false)}
disabled={isPending}
>
{t('Cancel')}
</Button>
<Button type='submit' form={PROVIDER_FORM_ID} disabled={isPending}>
{isPending
? t('Saving...')
: isEditing
? t('Update Provider')
: t('Create Provider')}
</Button>
</>
}
>
<Form {...form}>
<SettingsForm
id={PROVIDER_FORM_ID}
onSubmit={form.handleSubmit(onSubmit)}
>
{/* Preset Selector (only for creating) */}
{!isEditing && <PresetSelector form={form} />}
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='max-h-[85vh] overflow-y-auto sm:max-w-2xl'>
<DialogHeader>
<DialogTitle>
{isEditing ? t('Edit OAuth Provider') : t('Add OAuth Provider')}
</DialogTitle>
<DialogDescription>
{isEditing
? t('Update the configuration for this custom OAuth provider.')
: t(
'Configure a new custom OAuth provider for user authentication.'
)}
</DialogDescription>
</DialogHeader>
{/* Basic Info */}
<div className='space-y-4'>
<h4 className='text-sm font-medium'>{t('Basic Info')}</h4>
<Form {...form}>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
{/* Preset Selector (only for creating) */}
{!isEditing && <PresetSelector form={form} />}
<FormField
control={form.control}
name='enabled'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enabled')}</FormLabel>
<FormDescription>
{t('Allow users to sign in with this provider')}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
{/* Basic Info */}
<div className='space-y-4'>
<h4 className='text-sm font-medium'>{t('Basic Info')}</h4>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='name'
name='enabled'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Provider Name')}</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enabled')}</FormLabel>
<FormDescription>
{t('Allow users to sign in with this provider')}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Input placeholder={t('e.g. My GitLab')} {...field} />
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
</SettingsSwitchItem>
)}
/>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Provider Name')}</FormLabel>
<FormControl>
<Input placeholder={t('e.g. My GitLab')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='slug'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Slug')}</FormLabel>
<FormControl>
<Input placeholder={t('e.g. my-gitlab')} {...field} />
</FormControl>
<FormDescription>
{t('Used in URLs and API routes')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='slug'
name='icon'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Slug')}</FormLabel>
<FormLabel>{t('Icon')}</FormLabel>
<FormControl>
<Input placeholder={t('e.g. my-gitlab')} {...field} />
<Input
placeholder={t('Icon identifier (e.g. github, gitlab)')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Used in URLs and API routes')}
{t('Optional icon identifier for the login button')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -267,43 +273,137 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
/>
</div>
<FormField
control={form.control}
name='icon'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Icon')}</FormLabel>
<FormControl>
<Input
placeholder={t('Icon identifier (e.g. github, gitlab)')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Optional icon identifier for the login button')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Separator />
<Separator />
{/* Credentials */}
<div className='space-y-4'>
<h4 className='text-sm font-medium'>{t('Credentials')}</h4>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='client_id'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Client ID')}</FormLabel>
<FormControl>
<Input
placeholder={t('OAuth Client ID')}
autoComplete='off'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='client_secret'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Client Secret')}</FormLabel>
<FormControl>
<Input
type='password'
placeholder={t('OAuth Client Secret')}
autoComplete='new-password'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Credentials */}
<div className='space-y-4'>
<h4 className='text-sm font-medium'>{t('Credentials')}</h4>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='client_id'
name='auth_style'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Client ID')}</FormLabel>
<FormLabel>{t('Auth Style')}</FormLabel>
<Select
items={[
...AUTH_STYLE_OPTIONS.map((option) => ({
value: String(option.value),
label: t(option.labelKey),
})),
]}
value={String(field.value)}
onValueChange={(val) => field.onChange(Number(val))}
>
<FormControl>
<SelectTrigger className='w-full'>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{AUTH_STYLE_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={String(option.value)}
>
{t(option.labelKey)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
{t(
'How client credentials are sent to the token endpoint'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Separator />
{/* Endpoints */}
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<h4 className='text-sm font-medium'>{t('Endpoints')}</h4>
<DiscoveryButton form={form} />
</div>
<FormField
control={form.control}
name='well_known'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Well-Known URL')}</FormLabel>
<FormControl>
<Input
placeholder={t('OAuth Client ID')}
autoComplete='off'
placeholder={t(
'https://provider.com/.well-known/openid-configuration'
)}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'OIDC discovery URL. Click "Auto-discover" to fetch endpoints automatically.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='authorization_endpoint'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Authorization Endpoint')}</FormLabel>
<FormControl>
<Input
placeholder='https://provider.com/oauth/authorize'
{...field}
/>
</FormControl>
@@ -314,15 +414,171 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
<FormField
control={form.control}
name='client_secret'
name='token_endpoint'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Client Secret')}</FormLabel>
<FormLabel>{t('Token Endpoint')}</FormLabel>
<FormControl>
<Input
type='password'
placeholder={t('OAuth Client Secret')}
autoComplete='new-password'
placeholder='https://provider.com/oauth/token'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='user_info_endpoint'
render={({ field }) => (
<FormItem>
<FormLabel>{t('User Info Endpoint')}</FormLabel>
<FormControl>
<Input
placeholder='https://provider.com/api/user'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='scopes'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Scopes')}</FormLabel>
<FormControl>
<Input
placeholder={t('e.g. openid profile email')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Space-separated OAuth scopes')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Separator />
{/* Field Mapping */}
<div className='space-y-4'>
<h4 className='text-sm font-medium'>{t('Field Mapping')}</h4>
<FormDescription>
{t(
'Map fields from the user info response to local user attributes. Supports nested paths (e.g. ocs.data.id).'
)}
</FormDescription>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='user_id_field'
render={({ field }) => (
<FormItem>
<FormLabel>{t('User ID Field')}</FormLabel>
<FormControl>
<Input placeholder='id' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='username_field'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Username Field')}</FormLabel>
<FormControl>
<Input placeholder='login' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='display_name_field'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Display Name Field')}</FormLabel>
<FormControl>
<Input placeholder='name' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='email_field'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Email Field')}</FormLabel>
<FormControl>
<Input placeholder='email' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<Separator />
{/* Advanced */}
<div className='space-y-4'>
<h4 className='text-sm font-medium'>{t('Advanced')}</h4>
<FormField
control={form.control}
name='access_policy'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Access Policy (JSON)')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'Optional JSON policy to restrict access based on user info fields'
)}
className='min-h-[80px] font-mono text-xs'
{...field}
/>
</FormControl>
<FormDescription>
{t(
'JSON-based access control rules. Leave empty to allow all users.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='access_denied_message'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Access Denied Message')}</FormLabel>
<FormControl>
<Input
placeholder={t(
'Custom message shown when access is denied'
)}
{...field}
/>
</FormControl>
@@ -332,276 +588,26 @@ export function ProviderFormDialog(props: ProviderFormDialogProps) {
/>
</div>
<FormField
control={form.control}
name='auth_style'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Auth Style')}</FormLabel>
<Select
items={[
...AUTH_STYLE_OPTIONS.map((option) => ({
value: String(option.value),
label: t(option.labelKey),
})),
]}
value={String(field.value)}
onValueChange={(val) => field.onChange(Number(val))}
>
<FormControl>
<SelectTrigger className='w-full'>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{AUTH_STYLE_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={String(option.value)}
>
{t(option.labelKey)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
{t('How client credentials are sent to the token endpoint')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Separator />
{/* Endpoints */}
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<h4 className='text-sm font-medium'>{t('Endpoints')}</h4>
<DiscoveryButton form={form} />
</div>
<FormField
control={form.control}
name='well_known'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Well-Known URL')}</FormLabel>
<FormControl>
<Input
placeholder={t(
'https://provider.com/.well-known/openid-configuration'
)}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'OIDC discovery URL. Click "Auto-discover" to fetch endpoints automatically.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='authorization_endpoint'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Authorization Endpoint')}</FormLabel>
<FormControl>
<Input
placeholder='https://provider.com/oauth/authorize'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='token_endpoint'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Token Endpoint')}</FormLabel>
<FormControl>
<Input
placeholder='https://provider.com/oauth/token'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='user_info_endpoint'
render={({ field }) => (
<FormItem>
<FormLabel>{t('User Info Endpoint')}</FormLabel>
<FormControl>
<Input
placeholder='https://provider.com/api/user'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='scopes'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Scopes')}</FormLabel>
<FormControl>
<Input
placeholder={t('e.g. openid profile email')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Space-separated OAuth scopes')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Separator />
{/* Field Mapping */}
<div className='space-y-4'>
<h4 className='text-sm font-medium'>{t('Field Mapping')}</h4>
<FormDescription>
{t(
'Map fields from the user info response to local user attributes. Supports nested paths (e.g. ocs.data.id).'
)}
</FormDescription>
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
name='user_id_field'
render={({ field }) => (
<FormItem>
<FormLabel>{t('User ID Field')}</FormLabel>
<FormControl>
<Input placeholder='id' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='username_field'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Username Field')}</FormLabel>
<FormControl>
<Input placeholder='login' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='display_name_field'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Display Name Field')}</FormLabel>
<FormControl>
<Input placeholder='name' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='email_field'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Email Field')}</FormLabel>
<FormControl>
<Input placeholder='email' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<Separator />
{/* Advanced */}
<div className='space-y-4'>
<h4 className='text-sm font-medium'>{t('Advanced')}</h4>
<FormField
control={form.control}
name='access_policy'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Access Policy (JSON)')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'Optional JSON policy to restrict access based on user info fields'
)}
className='min-h-[80px] font-mono text-xs'
{...field}
/>
</FormControl>
<FormDescription>
{t(
'JSON-based access control rules. Leave empty to allow all users.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='access_denied_message'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Access Denied Message')}</FormLabel>
<FormControl>
<Input
placeholder={t(
'Custom message shown when access is denied'
)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</SettingsForm>
</Form>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => props.onOpenChange(false)}
disabled={isPending}
>
{t('Cancel')}
</Button>
<Button type='submit' disabled={isPending}>
{isPending
? t('Saving...')
: isEditing
? t('Update Provider')
: t('Create Provider')}
</Button>
</DialogFooter>
</SettingsForm>
</Form>
</DialogContent>
</Dialog>
)
}
@@ -70,7 +70,7 @@ function SettingsPageFrame(props: SettingsPageFrameProps) {
<span className='truncate'>{props.title}</span>
<span
ref={setTitleStatusContainer}
className='inline-flex shrink-0'
className='inline-flex min-w-0 shrink-0 items-center'
/>
</span>
</SectionPageLayout.Title>
@@ -36,6 +36,14 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -64,7 +72,6 @@ import {
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { DateTimePicker } from '@/components/datetime-picker'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { SettingsSwitchField } from '../components/settings-form-layout'
import { SettingsSection } from '../components/settings-section'
@@ -98,8 +105,6 @@ const announcementSchema = z.object({
type AnnouncementFormValues = z.infer<typeof announcementSchema>
const ANNOUNCEMENT_FORM_ID = 'announcement-form'
const typeOptions = [
{
value: 'default',
@@ -455,157 +460,154 @@ export function AnnouncementsSection({
</div>
</div>
<Dialog
open={showDialog}
onOpenChange={setShowDialog}
title={
editingAnnouncement ? t('Edit Announcement') : t('Add Announcement')
}
description={t(
'Create or update system announcements for the dashboard'
)}
contentClassName='max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => setShowDialog(false)}
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent className='max-w-2xl'>
<DialogHeader>
<DialogTitle>
{editingAnnouncement
? t('Edit Announcement')
: t('Add Announcement')}
</DialogTitle>
<DialogDescription>
{t('Create or update system announcements for the dashboard')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmitForm)}
className='space-y-4'
>
{t('Cancel')}
</Button>
<Button type='submit' form={ANNOUNCEMENT_FORM_ID}>
{editingAnnouncement ? t('Update') : t('Add')}
</Button>
</>
}
>
<Form {...form}>
<form
id={ANNOUNCEMENT_FORM_ID}
onSubmit={form.handleSubmit(handleSubmitForm)}
className='space-y-4'
>
<FormField
control={form.control}
name='content'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Content')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'Enter announcement content (supports Markdown/HTML)'
)}
rows={4}
{...field}
/>
</FormControl>
<FormDescription>
{t('Maximum 500 characters. Supports Markdown and HTML.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='publishDate'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Publish Date')}</FormLabel>
<FormControl>
<DateTimePicker
value={field.value ? new Date(field.value) : undefined}
onChange={(date) =>
field.onChange(date ? date.toISOString() : '')
}
placeholder={t('Select publish date')}
/>
</FormControl>
<FormDescription>
{t(
'Date and time when this announcement should be displayed'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='type'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Type')}</FormLabel>
<Select
items={[
...typeOptions.map((option) => ({
value: option.value,
label: (
<div className='flex items-center gap-2'>
<div
className={`h-3 w-3 rounded-full ${option.color}`}
/>
{option.label}
</div>
),
})),
]}
onValueChange={field.onChange}
value={field.value}
>
<FormField
control={form.control}
name='content'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Content')}</FormLabel>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t('Select announcement type')}
/>
</SelectTrigger>
<Textarea
placeholder={t(
'Enter announcement content (supports Markdown/HTML)'
)}
rows={4}
{...field}
/>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{typeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<FormDescription>
{t('Maximum 500 characters. Supports Markdown and HTML.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='publishDate'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Publish Date')}</FormLabel>
<FormControl>
<DateTimePicker
value={field.value ? new Date(field.value) : undefined}
onChange={(date) =>
field.onChange(date ? date.toISOString() : '')
}
placeholder={t('Select publish date')}
/>
</FormControl>
<FormDescription>
{t(
'Date and time when this announcement should be displayed'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='type'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Type')}</FormLabel>
<Select
items={[
...typeOptions.map((option) => ({
value: option.value,
label: (
<div className='flex items-center gap-2'>
<div
className={`h-3 w-3 rounded-full ${option.color}`}
/>
{option.label}
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='extra'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Extra Notes (Optional)')}</FormLabel>
<FormControl>
<Input
placeholder={t('Additional information')}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Optional supplementary information (max 100 characters)'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
),
})),
]}
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t('Select announcement type')}
/>
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{typeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className='flex items-center gap-2'>
<div
className={`h-3 w-3 rounded-full ${option.color}`}
/>
{option.label}
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='extra'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Extra Notes (Optional)')}</FormLabel>
<FormControl>
<Input
placeholder={t('Additional information')}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Optional supplementary information (max 100 characters)'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => setShowDialog(false)}
>
{t('Cancel')}
</Button>
<Button type='submit'>
{editingAnnouncement ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
@@ -36,6 +36,14 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -62,7 +70,6 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { SettingsSwitchField } from '../components/settings-form-layout'
import { SettingsSection } from '../components/settings-section'
@@ -91,8 +98,6 @@ const createApiInfoSchema = (t: (key: string) => string) =>
type ApiInfoFormValues = z.infer<ReturnType<typeof createApiInfoSchema>>
const API_INFO_FORM_ID = 'api-info-form'
const colorOptions = [
{ value: 'blue', label: 'Blue' },
{ value: 'green', label: 'Green' },
@@ -403,133 +408,133 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
</div>
</div>
<Dialog
open={showDialog}
onOpenChange={setShowDialog}
title={editingApiInfo ? t('Edit API Shortcut') : t('Add API Shortcut')}
description={t('Configure API documentation links for the dashboard')}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => setShowDialog(false)}
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingApiInfo ? t('Edit API Shortcut') : t('Add API Shortcut')}
</DialogTitle>
<DialogDescription>
{t('Configure API documentation links for the dashboard')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmitForm)}
className='space-y-4'
>
{t('Cancel')}
</Button>
<Button type='submit' form={API_INFO_FORM_ID}>
{editingApiInfo ? t('Update') : t('Add')}
</Button>
</>
}
>
<Form {...form}>
<form
id={API_INFO_FORM_ID}
onSubmit={form.handleSubmit(handleSubmitForm)}
className='space-y-4'
>
<FormField
control={form.control}
name='url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('API URL')}</FormLabel>
<FormControl>
<Input
placeholder={t('https://api.example.com')}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='route'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Route Description')}</FormLabel>
<FormControl>
<Input placeholder={t('e.g., CN2 GIA')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Description')}</FormLabel>
<FormControl>
<Input
placeholder={t(
'e.g., Recommended for China Mainland Users'
)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='color'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Badge Color')}</FormLabel>
<Select
items={[
...colorOptions.map((option) => ({
value: option.value,
label: (
<div className='flex items-center gap-2'>
<div
className={`h-4 w-4 rounded-full ${getBgColorClass(option.value)}`}
/>
{option.label}
</div>
),
})),
]}
onValueChange={field.onChange}
value={field.value}
>
<FormField
control={form.control}
name='url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('API URL')}</FormLabel>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('Select a color')} />
</SelectTrigger>
<Input
placeholder={t('https://api.example.com')}
{...field}
/>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{colorOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='route'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Route Description')}</FormLabel>
<FormControl>
<Input placeholder={t('e.g., CN2 GIA')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Description')}</FormLabel>
<FormControl>
<Input
placeholder={t(
'e.g., Recommended for China Mainland Users'
)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='color'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Badge Color')}</FormLabel>
<Select
items={[
...colorOptions.map((option) => ({
value: option.value,
label: (
<div className='flex items-center gap-2'>
<div
className={`h-4 w-4 rounded-full ${getBgColorClass(option.value)}`}
/>
{option.label}
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
{t('Visual indicator color for the API card')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
),
})),
]}
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('Select a color')} />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{colorOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className='flex items-center gap-2'>
<div
className={`h-4 w-4 rounded-full ${getBgColorClass(option.value)}`}
/>
{option.label}
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
{t('Visual indicator color for the API card')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => setShowDialog(false)}
>
{t('Cancel')}
</Button>
<Button type='submit'>
{editingApiInfo ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
@@ -22,6 +22,14 @@ import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -32,7 +40,6 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog'
const createChatDialogSchema = (t: (key: string) => string) =>
z.object({
@@ -42,8 +49,6 @@ const createChatDialogSchema = (t: (key: string) => string) =>
type ChatDialogFormValues = z.infer<ReturnType<typeof createChatDialogSchema>>
const CHAT_DIALOG_FORM_ID = 'chat-dialog-form'
export type ChatEntryData = {
name: string
url: string
@@ -92,73 +97,74 @@ export function ChatDialog({
}
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={isEditMode ? t('Edit chat preset') : t('Add chat preset')}
description={t('Configure a predefined chat link for end users.')}
contentClassName='sm:max-w-[500px]'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='submit' form={CHAT_DIALOG_FORM_ID}>
{isEditMode ? t('Update') : t('Add')}
</Button>
</>
}
>
<Form {...form}>
<form
id={CHAT_DIALOG_FORM_ID}
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4'
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Chat Client Name')}</FormLabel>
<FormControl>
<Input
placeholder={t('Please enter chat client name')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Display name for this chat client.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>
{isEditMode ? t('Edit chat preset') : t('Add chat preset')}
</DialogTitle>
<DialogDescription>
{t('Configure a predefined chat link for end users.')}
</DialogDescription>
</DialogHeader>
<FormField
control={form.control}
name='url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('URL')}</FormLabel>
<FormControl>
<Input placeholder={t('Please enter the URL')} {...field} />
</FormControl>
<FormDescription>
{t('The URL for this chat client.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-4'
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Chat Client Name')}</FormLabel>
<FormControl>
<Input
placeholder={t('Please enter chat client name')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Display name for this chat client.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('URL')}</FormLabel>
<FormControl>
<Input placeholder={t('Please enter the URL')} {...field} />
</FormControl>
<FormDescription>
{t('The URL for this chat client.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='submit'>
{isEditMode ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}
@@ -35,6 +35,14 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -54,7 +62,6 @@ import {
TableRow,
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { SettingsSwitchField } from '../components/settings-form-layout'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
@@ -83,8 +90,6 @@ const faqSchema = z.object({
type FAQFormValues = z.infer<typeof faqSchema>
const FAQ_FORM_ID = 'faq-form'
export function FAQSection({ enabled, data }: FAQSectionProps) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
@@ -343,78 +348,79 @@ export function FAQSection({ enabled, data }: FAQSectionProps) {
</div>
</div>
<Dialog
open={showDialog}
onOpenChange={setShowDialog}
title={editingFaq ? t('Edit FAQ') : t('Add FAQ')}
description={t('Create or update frequently asked questions for users')}
contentClassName='max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => setShowDialog(false)}
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent className='max-w-2xl'>
<DialogHeader>
<DialogTitle>
{editingFaq ? t('Edit FAQ') : t('Add FAQ')}
</DialogTitle>
<DialogDescription>
{t('Create or update frequently asked questions for users')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmitForm)}
className='space-y-4'
>
{t('Cancel')}
</Button>
<Button type='submit' form={FAQ_FORM_ID}>
{editingFaq ? t('Update') : t('Add')}
</Button>
</>
}
>
<Form {...form}>
<form
id={FAQ_FORM_ID}
onSubmit={form.handleSubmit(handleSubmitForm)}
className='space-y-4'
>
<FormField
control={form.control}
name='question'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Question')}</FormLabel>
<FormControl>
<Input
placeholder={t('How to reset my quota?')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Maximum 200 characters')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='answer'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Answer')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'Visit Settings → General and adjust quota options...'
<FormField
control={form.control}
name='question'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Question')}</FormLabel>
<FormControl>
<Input
placeholder={t('How to reset my quota?')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Maximum 200 characters')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='answer'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Answer')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'Visit Settings → General and adjust quota options...'
)}
rows={8}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Maximum 1000 characters. Supports Markdown and HTML.'
)}
rows={8}
{...field}
/>
</FormControl>
<FormDescription>
{t('Maximum 1000 characters. Supports Markdown and HTML.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => setShowDialog(false)}
>
{t('Cancel')}
</Button>
<Button type='submit'>
{editingFaq ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
@@ -35,6 +35,14 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
@@ -53,7 +61,6 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Dialog } from '@/components/dialog'
import { SettingsSwitchField } from '../components/settings-form-layout'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
@@ -90,8 +97,6 @@ const createUptimeKumaSchema = (t: (key: string) => string) =>
type UptimeKumaFormValues = z.infer<ReturnType<typeof createUptimeKumaSchema>>
const UPTIME_KUMA_FORM_ID = 'uptime-kuma-form'
export function UptimeKumaSection({ enabled, data }: UptimeKumaSectionProps) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
@@ -354,100 +359,96 @@ export function UptimeKumaSection({ enabled, data }: UptimeKumaSectionProps) {
</div>
</div>
<Dialog
open={showDialog}
onOpenChange={setShowDialog}
title={
editingGroup
? t('Edit Uptime Kuma Group')
: t('Add Uptime Kuma Group')
}
description={t(
'Configure monitoring status page groups for the dashboard'
)}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => setShowDialog(false)}
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editingGroup
? t('Edit Uptime Kuma Group')
: t('Add Uptime Kuma Group')}
</DialogTitle>
<DialogDescription>
{t('Configure monitoring status page groups for the dashboard')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmitForm)}
className='space-y-4'
>
{t('Cancel')}
</Button>
<Button type='submit' form={UPTIME_KUMA_FORM_ID}>
{editingGroup ? t('Update') : t('Add')}
</Button>
</>
}
>
<Form {...form}>
<form
id={UPTIME_KUMA_FORM_ID}
onSubmit={form.handleSubmit(handleSubmitForm)}
className='space-y-4'
>
<FormField
control={form.control}
name='categoryName'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Category Name')}</FormLabel>
<FormControl>
<Input
placeholder={t('e.g., Core APIs, OpenAI, Claude')}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Display name for this monitoring group (max 50 characters)'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Uptime Kuma URL')}</FormLabel>
<FormControl>
<Input
placeholder={t('https://status.example.com')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Base URL of your Uptime Kuma instance')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='slug'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Status Page Slug')}</FormLabel>
<FormControl>
<Input placeholder={t('my-status')} {...field} />
</FormControl>
<FormDescription>
{t('The slug is appended to the URL:')} {'{url}'}
{t('/status/')}
{'{slug}'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<FormField
control={form.control}
name='categoryName'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Category Name')}</FormLabel>
<FormControl>
<Input
placeholder={t('e.g., Core APIs, OpenAI, Claude')}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Display name for this monitoring group (max 50 characters)'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='url'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Uptime Kuma URL')}</FormLabel>
<FormControl>
<Input
placeholder={t('https://status.example.com')}
{...field}
/>
</FormControl>
<FormDescription>
{t('Base URL of your Uptime Kuma instance')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='slug'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Status Page Slug')}</FormLabel>
<FormControl>
<Input placeholder={t('my-status')} {...field} />
</FormControl>
<FormDescription>
{t('The slug is appended to the URL:')} {'{url}'}
{t('/status/')}
{'{slug}'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => setShowDialog(false)}
>
{t('Cancel')}
</Button>
<Button type='submit'>
{editingGroup ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
@@ -20,7 +20,12 @@ import { useEffect, useMemo, useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { formatTimestampToDate } from '@/lib/format'
import { Dialog } from '@/components/dialog'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { getAffinityUsageCache } from './api'
function formatRate(hit: number, total: number): string {
@@ -130,42 +135,38 @@ export function CacheStatsDialog(props: Props) {
}, [stats, props.target, t])
return (
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={t('Channel Affinity: Upstream Cache Hit')}
contentClassName='sm:max-w-lg'
contentHeight='auto'
bodyClassName='space-y-4'
>
<p className='text-muted-foreground text-xs'>
{t(
'Hit criteria: If cached tokens exist in usage, it counts as a hit.'
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Channel Affinity: Upstream Cache Hit')}</DialogTitle>
</DialogHeader>
<p className='text-muted-foreground text-xs'>
{t(
'Hit criteria: If cached tokens exist in usage, it counts as a hit.'
)}
</p>
{loading ? (
<div className='text-muted-foreground py-8 text-center text-sm'>
{t('Loading...')}
</div>
) : rows.length > 0 ? (
<div className='space-y-2'>
{rows.map((row) => (
<div
key={row.key}
className='flex justify-between border-b pb-1 text-sm'
>
<span className='text-muted-foreground'>{row.key}</span>
<span className='font-medium'>{row.value}</span>
</div>
))}
</div>
) : (
<div className='text-muted-foreground py-8 text-center text-sm'>
{t('No data available')}
</div>
)}
</p>
{loading ? (
<div className='text-muted-foreground py-8 text-center text-sm'>
{t('Loading...')}
</div>
) : rows.length > 0 ? (
<div className='space-y-2'>
{rows.map((row) => (
<div
key={row.key}
className='flex justify-between gap-4 border-b pb-1 text-sm'
>
<span className='text-muted-foreground'>{row.key}</span>
<span className='text-right font-medium break-all'>
{row.value}
</span>
</div>
))}
</div>
) : (
<div className='text-muted-foreground py-8 text-center text-sm'>
{t('No data available')}
</div>
)}
</DialogContent>
</Dialog>
)
}

Some files were not shown because too many files have changed in this diff Show More