Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f415428d3 | |||
| 59a93cf5c7 | |||
| 867d8acfc3 | |||
| 30d3a3a5f7 | |||
| d2576ddcd3 | |||
| 4ca47ee236 | |||
| 16dd7237c0 | |||
| 1915344838 | |||
| 15ff8e0268 | |||
| a1c82841b5 | |||
| 2eaa943d9f | |||
| 7a5348caa3 | |||
| f5753a2b31 | |||
| e8c36762fd | |||
| e2dbd02cbb | |||
| c8d3768087 | |||
| 979aeceb5c |
+2
-2
@@ -112,11 +112,11 @@ func InitEnv() {
|
||||
|
||||
// Initialize rate limit variables
|
||||
GlobalApiRateLimitEnable = GetEnvOrDefaultBool("GLOBAL_API_RATE_LIMIT_ENABLE", true)
|
||||
GlobalApiRateLimitNum = GetEnvOrDefault("GLOBAL_API_RATE_LIMIT", 180)
|
||||
GlobalApiRateLimitNum = GetEnvOrDefault("GLOBAL_API_RATE_LIMIT", 360)
|
||||
GlobalApiRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_API_RATE_LIMIT_DURATION", 180))
|
||||
|
||||
GlobalWebRateLimitEnable = GetEnvOrDefaultBool("GLOBAL_WEB_RATE_LIMIT_ENABLE", true)
|
||||
GlobalWebRateLimitNum = GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT", 60)
|
||||
GlobalWebRateLimitNum = GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT", 120)
|
||||
GlobalWebRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT_DURATION", 180))
|
||||
|
||||
CriticalRateLimitEnable = GetEnvOrDefaultBool("CRITICAL_RATE_LIMIT_ENABLE", true)
|
||||
|
||||
+6
-6
@@ -26,11 +26,11 @@ type ImageRequest struct {
|
||||
OutputFormat json.RawMessage `json:"output_format,omitempty"`
|
||||
OutputCompression json.RawMessage `json:"output_compression,omitempty"`
|
||||
PartialImages json.RawMessage `json:"partial_images,omitempty"`
|
||||
// Stream bool `json:"stream,omitempty"`
|
||||
Images json.RawMessage `json:"images,omitempty"`
|
||||
Mask json.RawMessage `json:"mask,omitempty"`
|
||||
InputFidelity json.RawMessage `json:"input_fidelity,omitempty"`
|
||||
Watermark *bool `json:"watermark,omitempty"`
|
||||
Stream *bool `json:"stream,omitempty"`
|
||||
Images json.RawMessage `json:"images,omitempty"`
|
||||
Mask json.RawMessage `json:"mask,omitempty"`
|
||||
InputFidelity json.RawMessage `json:"input_fidelity,omitempty"`
|
||||
Watermark *bool `json:"watermark,omitempty"`
|
||||
// zhipu 4v
|
||||
WatermarkEnabled json.RawMessage `json:"watermark_enabled,omitempty"`
|
||||
UserId json.RawMessage `json:"user_id,omitempty"`
|
||||
@@ -163,7 +163,7 @@ func (i *ImageRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
}
|
||||
|
||||
func (i *ImageRequest) IsStream(c *gin.Context) bool {
|
||||
return false
|
||||
return i.Stream != nil && *i.Stream
|
||||
}
|
||||
|
||||
func (i *ImageRequest) SetModelName(modelName string) {
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
channelconstant "github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/relay/channel"
|
||||
@@ -79,9 +81,23 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
|
||||
if request.Temperature != nil && isTemperatureOneOnlyModel(getUpstreamModelName(info, request.Model)) && *request.Temperature != 1.0 {
|
||||
request.Temperature = common.GetPointer[float64](1.0)
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func getUpstreamModelName(info *relaycommon.RelayInfo, fallback string) string {
|
||||
if info != nil && info.ChannelMeta != nil && info.UpstreamModelName != "" {
|
||||
return info.UpstreamModelName
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func isTemperatureOneOnlyModel(model string) bool {
|
||||
return strings.EqualFold(model, "kimi-k2.6")
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
|
||||
// TODO implement me
|
||||
return nil, errors.New("not implemented")
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package moonshot
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConvertOpenAIRequestKimiK26UsesOnlyAllowedTemperature(t *testing.T) {
|
||||
request := &dto.GeneralOpenAIRequest{
|
||||
Model: "kimi-k2.6",
|
||||
Temperature: common.GetPointer[float64](0.7),
|
||||
}
|
||||
info := &relaycommon.RelayInfo{
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "kimi-k2.6",
|
||||
},
|
||||
}
|
||||
|
||||
converted, err := (&Adaptor{}).ConvertOpenAIRequest(nil, info, request)
|
||||
|
||||
require.NoError(t, err)
|
||||
convertedRequest, ok := converted.(*dto.GeneralOpenAIRequest)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, convertedRequest.Temperature)
|
||||
require.Equal(t, 1.0, *convertedRequest.Temperature)
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestKimiK26KeepsOmittedTemperatureOmitted(t *testing.T) {
|
||||
request := &dto.GeneralOpenAIRequest{
|
||||
Model: "kimi-k2.6",
|
||||
}
|
||||
info := &relaycommon.RelayInfo{
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "kimi-k2.6",
|
||||
},
|
||||
}
|
||||
|
||||
converted, err := (&Adaptor{}).ConvertOpenAIRequest(nil, info, request)
|
||||
|
||||
require.NoError(t, err)
|
||||
convertedRequest, ok := converted.(*dto.GeneralOpenAIRequest)
|
||||
require.True(t, ok)
|
||||
require.Nil(t, convertedRequest.Temperature)
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestOtherMoonshotModelKeepsTemperature(t *testing.T) {
|
||||
request := &dto.GeneralOpenAIRequest{
|
||||
Model: "kimi-k2.5",
|
||||
Temperature: common.GetPointer[float64](0.7),
|
||||
}
|
||||
info := &relaycommon.RelayInfo{
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "kimi-k2.5",
|
||||
},
|
||||
}
|
||||
|
||||
converted, err := (&Adaptor{}).ConvertOpenAIRequest(nil, info, request)
|
||||
|
||||
require.NoError(t, err)
|
||||
convertedRequest, ok := converted.(*dto.GeneralOpenAIRequest)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, convertedRequest.Temperature)
|
||||
require.Equal(t, 0.7, *convertedRequest.Temperature)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -439,10 +440,13 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
|
||||
// 使用已解析的 multipart 表单,避免重复解析
|
||||
mf := c.Request.MultipartForm
|
||||
if mf == nil {
|
||||
if _, err := c.MultipartForm(); err != nil {
|
||||
return nil, errors.New("failed to parse multipart form")
|
||||
form, err := common.ParseMultipartFormReusable(c)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse multipart form: %w", err)
|
||||
}
|
||||
mf = c.Request.MultipartForm
|
||||
c.Request.MultipartForm = form
|
||||
c.Request.PostForm = url.Values(form.Value)
|
||||
mf = form
|
||||
}
|
||||
|
||||
// 写入所有非文件字段
|
||||
@@ -625,7 +629,11 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
|
||||
case relayconstant.RelayModeAudioTranscription:
|
||||
err, usage = OpenaiSTTHandler(c, resp, info, a.ResponseFormat)
|
||||
case relayconstant.RelayModeImagesGenerations, relayconstant.RelayModeImagesEdits:
|
||||
usage, err = OpenaiHandlerWithUsage(c, info, resp)
|
||||
if info.IsStream {
|
||||
usage, err = OpenaiImageStreamHandler(c, info, resp)
|
||||
} else {
|
||||
usage, err = OpenaiImageHandler(c, info, resp)
|
||||
}
|
||||
case relayconstant.RelayModeRerank:
|
||||
usage, err = common_handler.RerankHandler(c, info, resp)
|
||||
case relayconstant.RelayModeResponses:
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestConvertImageEditRequestMultipart verifies that ConvertImageRequest
|
||||
// re-serializes multipart image edit requests with all fields (including
|
||||
// stream) and the file intact, both when the form was already parsed and when
|
||||
// it must be re-parsed from the reusable body.
|
||||
func TestConvertImageEditRequestMultipart(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
newMultipartContext := func(t *testing.T, prompt string) *gin.Context {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
require.NoError(t, writer.WriteField("model", "gpt-image-1"))
|
||||
require.NoError(t, writer.WriteField("prompt", prompt))
|
||||
require.NoError(t, writer.WriteField("stream", "true"))
|
||||
require.NoError(t, writer.WriteField("partial_images", "3"))
|
||||
part, err := writer.CreateFormFile("image", "input.png")
|
||||
require.NoError(t, err)
|
||||
_, err = part.Write([]byte("fake image"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer.Close())
|
||||
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body)
|
||||
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
return c
|
||||
}
|
||||
|
||||
convertAndReplay := func(t *testing.T, c *gin.Context, prompt string) {
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayMode: relayconstant.RelayModeImagesEdits,
|
||||
}
|
||||
request := dto.ImageRequest{
|
||||
Model: "gpt-image-1",
|
||||
Prompt: prompt,
|
||||
Stream: common.GetPointer(true),
|
||||
}
|
||||
|
||||
converted, err := (&Adaptor{}).ConvertImageRequest(c, info, request)
|
||||
require.NoError(t, err)
|
||||
convertedBody, ok := converted.(*bytes.Buffer)
|
||||
require.True(t, ok)
|
||||
|
||||
replayedRequest := httptest.NewRequest(http.MethodPost, "/v1/images/edits", bytes.NewReader(convertedBody.Bytes()))
|
||||
replayedRequest.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
|
||||
require.NoError(t, replayedRequest.ParseMultipartForm(32<<20))
|
||||
|
||||
require.Equal(t, "gpt-image-1", replayedRequest.PostForm.Get("model"))
|
||||
require.Equal(t, prompt, replayedRequest.PostForm.Get("prompt"))
|
||||
require.Equal(t, "true", replayedRequest.PostForm.Get("stream"))
|
||||
require.Equal(t, "3", replayedRequest.PostForm.Get("partial_images"))
|
||||
require.Len(t, replayedRequest.MultipartForm.File["image"], 1)
|
||||
|
||||
file, err := replayedRequest.MultipartForm.File["image"][0].Open()
|
||||
require.NoError(t, err)
|
||||
defer file.Close()
|
||||
fileBytes, err := io.ReadAll(file)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("fake image"), fileBytes)
|
||||
}
|
||||
|
||||
t.Run("with pre-parsed form", func(t *testing.T) {
|
||||
prompt := "edit this image"
|
||||
c := newMultipartContext(t, prompt)
|
||||
require.NoError(t, c.Request.ParseMultipartForm(32<<20))
|
||||
|
||||
convertAndReplay(t, c, prompt)
|
||||
})
|
||||
|
||||
t.Run("re-parses reusable body when form is missing", func(t *testing.T) {
|
||||
prompt := "edit without pre-parsed form"
|
||||
c := newMultipartContext(t, prompt)
|
||||
|
||||
storage, err := common.GetBodyStorage(c)
|
||||
require.NoError(t, err)
|
||||
c.Request.Body = io.NopCloser(storage)
|
||||
c.Request.MultipartForm = nil
|
||||
c.Request.PostForm = nil
|
||||
|
||||
convertAndReplay(t, c, prompt)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newImageTestContext(t *testing.T, body, contentType string, isStream bool) (*gin.Context, *httptest.ResponseRecorder, *http.Response, *relaycommon.RelayInfo) {
|
||||
t.Helper()
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
|
||||
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: http.Header{"Content-Type": []string{contentType}},
|
||||
}
|
||||
info := &relaycommon.RelayInfo{
|
||||
ChannelMeta: &relaycommon.ChannelMeta{},
|
||||
IsStream: isStream,
|
||||
}
|
||||
return c, recorder, resp, info
|
||||
}
|
||||
|
||||
// TestOpenaiImageStreamHandlerForwardsSSEAndUsage covers the core SSE path:
|
||||
// chunks are forwarded with rebuilt event lines, usage is extracted and
|
||||
// normalized (input_tokens -> prompt_tokens with details), and [DONE] is
|
||||
// re-emitted to the client.
|
||||
func TestOpenaiImageStreamHandlerForwardsSSEAndUsage(t *testing.T) {
|
||||
oldMode := gin.Mode()
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Cleanup(func() { gin.SetMode(oldMode) })
|
||||
|
||||
oldTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 30
|
||||
t.Cleanup(func() { constant.StreamingTimeout = oldTimeout })
|
||||
|
||||
body := strings.Join([]string{
|
||||
`event: image_generation.partial_image`,
|
||||
`data: {"type":"image_generation.partial_image","b64_json":"partial"}`,
|
||||
``,
|
||||
`data: {"usage":{"input_tokens":3,"output_tokens":4,"total_tokens":7,"input_tokens_details":{"image_tokens":2,"text_tokens":1}}}`,
|
||||
``,
|
||||
`data: [DONE]`,
|
||||
``,
|
||||
}, "\n")
|
||||
|
||||
c, recorder, resp, info := newImageTestContext(t, body, "text/event-stream", true)
|
||||
|
||||
usage, err := OpenaiImageStreamHandler(c, info, resp)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, usage.PromptTokens)
|
||||
require.Equal(t, 4, usage.CompletionTokens)
|
||||
require.Equal(t, 7, usage.TotalTokens)
|
||||
require.Equal(t, 2, usage.PromptTokensDetails.ImageTokens)
|
||||
require.Equal(t, 1, usage.PromptTokensDetails.TextTokens)
|
||||
require.Contains(t, recorder.Body.String(), `event: image_generation.partial_image`)
|
||||
require.Contains(t, recorder.Body.String(), `data: {"type":"image_generation.partial_image","b64_json":"partial"}`)
|
||||
require.Contains(t, recorder.Body.String(), `data: {"usage":{"input_tokens":3,"output_tokens":4,"total_tokens":7,"input_tokens_details":{"image_tokens":2,"text_tokens":1}}}`)
|
||||
require.Contains(t, recorder.Body.String(), `data: [DONE]`)
|
||||
require.Equal(t, "text/event-stream", recorder.Header().Get("Content-Type"))
|
||||
}
|
||||
|
||||
// TestOpenaiImageStreamHandlerWrapsJSONResponse covers the non-SSE fallback:
|
||||
// a JSON upstream response is wrapped into pseudo-SSE completed events.
|
||||
func TestOpenaiImageStreamHandlerWrapsJSONResponse(t *testing.T) {
|
||||
oldMode := gin.Mode()
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Cleanup(func() { gin.SetMode(oldMode) })
|
||||
|
||||
body := `{"created":1710000000,"data":[{"b64_json":"final","revised_prompt":"draw a cat"}],"usage":{"input_tokens":3,"output_tokens":4,"total_tokens":7,"input_tokens_details":{"image_tokens":2,"text_tokens":1}}}`
|
||||
|
||||
c, recorder, resp, info := newImageTestContext(t, body, "application/json", true)
|
||||
|
||||
usage, err := OpenaiImageStreamHandler(c, info, resp)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, usage.PromptTokens)
|
||||
require.Equal(t, 4, usage.CompletionTokens)
|
||||
require.Equal(t, 7, usage.TotalTokens)
|
||||
require.Equal(t, 2, usage.PromptTokensDetails.ImageTokens)
|
||||
require.Equal(t, 1, usage.PromptTokensDetails.TextTokens)
|
||||
require.Equal(t, "text/event-stream", recorder.Header().Get("Content-Type"))
|
||||
require.Empty(t, recorder.Header().Get("Content-Length"))
|
||||
require.Contains(t, recorder.Body.String(), `event: image_generation.completed`)
|
||||
require.Contains(t, recorder.Body.String(), `"type":"image_generation.completed"`)
|
||||
require.Contains(t, recorder.Body.String(), `"b64_json":"final"`)
|
||||
require.Contains(t, recorder.Body.String(), `"revised_prompt":"draw a cat"`)
|
||||
require.Contains(t, recorder.Body.String(), `data: [DONE]`)
|
||||
}
|
||||
|
||||
// TestOpenaiImageHandlersReturnJSONError covers JSON error responses for both
|
||||
// entry points: the non-streaming handler and the stream handler's non-SSE
|
||||
// fallback. Neither must leak the error body to the client.
|
||||
func TestOpenaiImageHandlersReturnJSONError(t *testing.T) {
|
||||
oldMode := gin.Mode()
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Cleanup(func() { gin.SetMode(oldMode) })
|
||||
|
||||
body := `{"error":{"message":"content moderation failed","type":"upstream_error","code":"content_moderation_failed","status":502}}`
|
||||
|
||||
t.Run("non-streaming handler", func(t *testing.T) {
|
||||
c, recorder, resp, info := newImageTestContext(t, body, "application/json", false)
|
||||
|
||||
usage, err := OpenaiImageHandler(c, info, resp)
|
||||
require.Nil(t, usage)
|
||||
require.NotNil(t, err)
|
||||
require.Equal(t, http.StatusOK, err.StatusCode)
|
||||
oaiError := err.ToOpenAIError()
|
||||
require.Equal(t, "content moderation failed", oaiError.Message)
|
||||
require.Equal(t, "upstream_error", oaiError.Type)
|
||||
require.Equal(t, "content_moderation_failed", oaiError.Code)
|
||||
require.Empty(t, recorder.Body.String())
|
||||
})
|
||||
|
||||
t.Run("stream handler JSON fallback", func(t *testing.T) {
|
||||
c, recorder, resp, info := newImageTestContext(t, body, "application/json", true)
|
||||
|
||||
usage, err := OpenaiImageStreamHandler(c, info, resp)
|
||||
require.Nil(t, usage)
|
||||
require.NotNil(t, err)
|
||||
require.Equal(t, http.StatusOK, err.StatusCode)
|
||||
require.Equal(t, "content moderation failed", err.ToOpenAIError().Message)
|
||||
require.Empty(t, recorder.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
// TestOpenaiImageStreamHandlerRecordsUpstreamErrorEvent verifies that an error
|
||||
// event inside the SSE stream is recorded as a soft error while the payload is
|
||||
// still forwarded to the client.
|
||||
func TestOpenaiImageStreamHandlerRecordsUpstreamErrorEvent(t *testing.T) {
|
||||
oldMode := gin.Mode()
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Cleanup(func() { gin.SetMode(oldMode) })
|
||||
|
||||
oldTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 30
|
||||
t.Cleanup(func() { constant.StreamingTimeout = oldTimeout })
|
||||
|
||||
body := strings.Join([]string{
|
||||
`event: image_generation.partial_image`,
|
||||
`data: {"type":"image_generation.partial_image","b64_json":"partial"}`,
|
||||
``,
|
||||
`event: error`,
|
||||
`data: {"type":"upstream_error","error":{"message":"stream error: stream ID 77; INTERNAL_ERROR; received from peer"}}`,
|
||||
``,
|
||||
}, "\n")
|
||||
|
||||
c, recorder, resp, info := newImageTestContext(t, body, "text/event-stream", true)
|
||||
|
||||
usage, err := OpenaiImageStreamHandler(c, info, resp)
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, usage)
|
||||
require.NotNil(t, info.StreamStatus)
|
||||
require.Equal(t, relaycommon.StreamEndReasonEOF, info.StreamStatus.EndReason)
|
||||
require.True(t, info.StreamStatus.HasErrors())
|
||||
require.Equal(t, 1, info.StreamStatus.TotalErrorCount())
|
||||
require.Contains(t, info.StreamStatus.Errors[0].Message, "INTERNAL_ERROR")
|
||||
// The scanner strips the upstream "event: error" line; the event name is
|
||||
// rebuilt from the JSON "type" field (upstream_error). The error message
|
||||
// is still forwarded in the data: payload (stream ID 77).
|
||||
require.Contains(t, recorder.Body.String(), `event: upstream_error`)
|
||||
require.Contains(t, recorder.Body.String(), `stream ID 77`)
|
||||
}
|
||||
@@ -14,12 +14,9 @@ import (
|
||||
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"
|
||||
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func sendStreamData(c *gin.Context, info *relaycommon.RelayInfo, data string, forceFormat bool, thinkToContent bool) error {
|
||||
@@ -293,421 +290,3 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
|
||||
|
||||
return &simpleResponse.Usage, nil
|
||||
}
|
||||
|
||||
func streamTTSResponse(c *gin.Context, resp *http.Response) {
|
||||
c.Writer.WriteHeaderNow()
|
||||
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
logger.LogWarn(c, "streaming not supported")
|
||||
_, err := io.Copy(c.Writer, resp.Body)
|
||||
if err != nil {
|
||||
logger.LogWarn(c, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
buffer := make([]byte, 4096)
|
||||
for {
|
||||
n, err := resp.Body.Read(buffer)
|
||||
//logger.LogInfo(c, fmt.Sprintf("streamTTSResponse read %d bytes", n))
|
||||
if n > 0 {
|
||||
if _, writeErr := c.Writer.Write(buffer[:n]); writeErr != nil {
|
||||
logger.LogError(c, writeErr.Error())
|
||||
break
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
logger.LogError(c, err.Error())
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.RealtimeUsage) {
|
||||
if info == nil || info.ClientWs == nil || info.TargetWs == nil {
|
||||
return types.NewError(fmt.Errorf("invalid websocket connection"), types.ErrorCodeBadResponse), nil
|
||||
}
|
||||
|
||||
info.IsStream = true
|
||||
clientConn := info.ClientWs
|
||||
targetConn := info.TargetWs
|
||||
|
||||
clientClosed := make(chan struct{})
|
||||
targetClosed := make(chan struct{})
|
||||
sendChan := make(chan []byte, 100)
|
||||
receiveChan := make(chan []byte, 100)
|
||||
errChan := make(chan error, 2)
|
||||
|
||||
usage := &dto.RealtimeUsage{}
|
||||
localUsage := &dto.RealtimeUsage{}
|
||||
sumUsage := &dto.RealtimeUsage{}
|
||||
|
||||
gopool.Go(func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
errChan <- fmt.Errorf("panic in client reader: %v", r)
|
||||
}
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case <-c.Done():
|
||||
return
|
||||
default:
|
||||
_, message, err := clientConn.ReadMessage()
|
||||
if err != nil {
|
||||
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
||||
errChan <- fmt.Errorf("error reading from client: %v", err)
|
||||
}
|
||||
close(clientClosed)
|
||||
return
|
||||
}
|
||||
|
||||
realtimeEvent := &dto.RealtimeEvent{}
|
||||
err = common.Unmarshal(message, realtimeEvent)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error unmarshalling message: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if realtimeEvent.Type == dto.RealtimeEventTypeSessionUpdate {
|
||||
if realtimeEvent.Session != nil {
|
||||
if realtimeEvent.Session.Tools != nil {
|
||||
info.RealtimeTools = realtimeEvent.Session.Tools
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
textToken, audioToken, err := service.CountTokenRealtime(info, *realtimeEvent, info.UpstreamModelName)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error counting text token: %v", err)
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
|
||||
localUsage.TotalTokens += textToken + audioToken
|
||||
localUsage.InputTokens += textToken + audioToken
|
||||
localUsage.InputTokenDetails.TextTokens += textToken
|
||||
localUsage.InputTokenDetails.AudioTokens += audioToken
|
||||
|
||||
err = helper.WssString(c, targetConn, string(message))
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error writing to target: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case sendChan <- message:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
gopool.Go(func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
errChan <- fmt.Errorf("panic in target reader: %v", r)
|
||||
}
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case <-c.Done():
|
||||
return
|
||||
default:
|
||||
_, message, err := targetConn.ReadMessage()
|
||||
if err != nil {
|
||||
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
||||
errChan <- fmt.Errorf("error reading from target: %v", err)
|
||||
}
|
||||
close(targetClosed)
|
||||
return
|
||||
}
|
||||
info.SetFirstResponseTime()
|
||||
realtimeEvent := &dto.RealtimeEvent{}
|
||||
err = common.Unmarshal(message, realtimeEvent)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error unmarshalling message: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if realtimeEvent.Type == dto.RealtimeEventTypeResponseDone {
|
||||
realtimeUsage := realtimeEvent.Response.Usage
|
||||
if realtimeUsage != nil {
|
||||
usage.TotalTokens += realtimeUsage.TotalTokens
|
||||
usage.InputTokens += realtimeUsage.InputTokens
|
||||
usage.OutputTokens += realtimeUsage.OutputTokens
|
||||
usage.InputTokenDetails.AudioTokens += realtimeUsage.InputTokenDetails.AudioTokens
|
||||
usage.InputTokenDetails.CachedTokens += realtimeUsage.InputTokenDetails.CachedTokens
|
||||
usage.InputTokenDetails.TextTokens += realtimeUsage.InputTokenDetails.TextTokens
|
||||
usage.OutputTokenDetails.AudioTokens += realtimeUsage.OutputTokenDetails.AudioTokens
|
||||
usage.OutputTokenDetails.TextTokens += realtimeUsage.OutputTokenDetails.TextTokens
|
||||
err := preConsumeUsage(c, info, usage, sumUsage)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error consume usage: %v", err)
|
||||
return
|
||||
}
|
||||
// 本次计费完成,清除
|
||||
usage = &dto.RealtimeUsage{}
|
||||
|
||||
localUsage = &dto.RealtimeUsage{}
|
||||
} else {
|
||||
textToken, audioToken, err := service.CountTokenRealtime(info, *realtimeEvent, info.UpstreamModelName)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error counting text token: %v", err)
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
|
||||
localUsage.TotalTokens += textToken + audioToken
|
||||
info.IsFirstRequest = false
|
||||
localUsage.InputTokens += textToken + audioToken
|
||||
localUsage.InputTokenDetails.TextTokens += textToken
|
||||
localUsage.InputTokenDetails.AudioTokens += audioToken
|
||||
err = preConsumeUsage(c, info, localUsage, sumUsage)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error consume usage: %v", err)
|
||||
return
|
||||
}
|
||||
// 本次计费完成,清除
|
||||
localUsage = &dto.RealtimeUsage{}
|
||||
// print now usage
|
||||
}
|
||||
logger.LogInfo(c, fmt.Sprintf("realtime streaming sumUsage: %v", sumUsage))
|
||||
logger.LogInfo(c, fmt.Sprintf("realtime streaming localUsage: %v", localUsage))
|
||||
logger.LogInfo(c, fmt.Sprintf("realtime streaming localUsage: %v", localUsage))
|
||||
|
||||
} else if realtimeEvent.Type == dto.RealtimeEventTypeSessionUpdated || realtimeEvent.Type == dto.RealtimeEventTypeSessionCreated {
|
||||
realtimeSession := realtimeEvent.Session
|
||||
if realtimeSession != nil {
|
||||
// update audio format
|
||||
info.InputAudioFormat = common.GetStringIfEmpty(realtimeSession.InputAudioFormat, info.InputAudioFormat)
|
||||
info.OutputAudioFormat = common.GetStringIfEmpty(realtimeSession.OutputAudioFormat, info.OutputAudioFormat)
|
||||
}
|
||||
} else {
|
||||
textToken, audioToken, err := service.CountTokenRealtime(info, *realtimeEvent, info.UpstreamModelName)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error counting text token: %v", err)
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
|
||||
localUsage.TotalTokens += textToken + audioToken
|
||||
localUsage.OutputTokens += textToken + audioToken
|
||||
localUsage.OutputTokenDetails.TextTokens += textToken
|
||||
localUsage.OutputTokenDetails.AudioTokens += audioToken
|
||||
}
|
||||
|
||||
err = helper.WssString(c, clientConn, string(message))
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error writing to client: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case receiveChan <- message:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-clientClosed:
|
||||
case <-targetClosed:
|
||||
case err := <-errChan:
|
||||
//return service.OpenAIErrorWrapper(err, "realtime_error", http.StatusInternalServerError), nil
|
||||
logger.LogError(c, "realtime error: "+err.Error())
|
||||
case <-c.Done():
|
||||
}
|
||||
|
||||
if usage.TotalTokens != 0 {
|
||||
_ = preConsumeUsage(c, info, usage, sumUsage)
|
||||
}
|
||||
|
||||
if localUsage.TotalTokens != 0 {
|
||||
_ = preConsumeUsage(c, info, localUsage, sumUsage)
|
||||
}
|
||||
|
||||
// check usage total tokens, if 0, use local usage
|
||||
|
||||
return nil, sumUsage
|
||||
}
|
||||
|
||||
func preConsumeUsage(ctx *gin.Context, info *relaycommon.RelayInfo, usage *dto.RealtimeUsage, totalUsage *dto.RealtimeUsage) error {
|
||||
if usage == nil || totalUsage == nil {
|
||||
return fmt.Errorf("invalid usage pointer")
|
||||
}
|
||||
|
||||
totalUsage.TotalTokens += usage.TotalTokens
|
||||
totalUsage.InputTokens += usage.InputTokens
|
||||
totalUsage.OutputTokens += usage.OutputTokens
|
||||
totalUsage.InputTokenDetails.CachedTokens += usage.InputTokenDetails.CachedTokens
|
||||
totalUsage.InputTokenDetails.TextTokens += usage.InputTokenDetails.TextTokens
|
||||
totalUsage.InputTokenDetails.AudioTokens += usage.InputTokenDetails.AudioTokens
|
||||
totalUsage.OutputTokenDetails.TextTokens += usage.OutputTokenDetails.TextTokens
|
||||
totalUsage.OutputTokenDetails.AudioTokens += usage.OutputTokenDetails.AudioTokens
|
||||
// clear usage
|
||||
err := service.PreWssConsumeQuota(ctx, info, usage)
|
||||
return err
|
||||
}
|
||||
|
||||
func OpenaiHandlerWithUsage(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
defer service.CloseResponseBodyGracefully(resp)
|
||||
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
var usageResp dto.SimpleResponse
|
||||
err = common.Unmarshal(responseBody, &usageResp)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// 写入新的 response body
|
||||
service.IOCopyBytesGracefully(c, resp, responseBody)
|
||||
|
||||
// Once we've written to the client, we should not return errors anymore
|
||||
// because the upstream has already consumed resources and returned content
|
||||
// We should still perform billing even if parsing fails
|
||||
// format
|
||||
if usageResp.InputTokens > 0 {
|
||||
usageResp.PromptTokens += usageResp.InputTokens
|
||||
}
|
||||
if usageResp.OutputTokens > 0 {
|
||||
usageResp.CompletionTokens += usageResp.OutputTokens
|
||||
}
|
||||
if usageResp.InputTokensDetails != nil {
|
||||
usageResp.PromptTokensDetails.ImageTokens += usageResp.InputTokensDetails.ImageTokens
|
||||
usageResp.PromptTokensDetails.TextTokens += usageResp.InputTokensDetails.TextTokens
|
||||
}
|
||||
applyUsagePostProcessing(info, &usageResp.Usage, responseBody)
|
||||
return &usageResp.Usage, nil
|
||||
}
|
||||
|
||||
func applyUsagePostProcessing(info *relaycommon.RelayInfo, usage *dto.Usage, responseBody []byte) {
|
||||
if info == nil || usage == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch info.ChannelType {
|
||||
case constant.ChannelTypeDeepSeek:
|
||||
if usage.PromptTokensDetails.CachedTokens == 0 && usage.PromptCacheHitTokens != 0 {
|
||||
usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens
|
||||
}
|
||||
case constant.ChannelTypeZhipu_v4:
|
||||
// 智普的cached_tokens在标准位置: usage.prompt_tokens_details.cached_tokens
|
||||
if usage.PromptTokensDetails.CachedTokens == 0 {
|
||||
if usage.InputTokensDetails != nil && usage.InputTokensDetails.CachedTokens > 0 {
|
||||
usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens
|
||||
} else if cachedTokens, ok := extractCachedTokensFromBody(responseBody); ok {
|
||||
usage.PromptTokensDetails.CachedTokens = cachedTokens
|
||||
} else if usage.PromptCacheHitTokens > 0 {
|
||||
usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens
|
||||
}
|
||||
}
|
||||
case constant.ChannelTypeMoonshot:
|
||||
// Moonshot的cached_tokens在非标准位置: choices[].usage.cached_tokens
|
||||
if usage.PromptTokensDetails.CachedTokens == 0 {
|
||||
if usage.InputTokensDetails != nil && usage.InputTokensDetails.CachedTokens > 0 {
|
||||
usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens
|
||||
} else if cachedTokens, ok := extractMoonshotCachedTokensFromBody(responseBody); ok {
|
||||
usage.PromptTokensDetails.CachedTokens = cachedTokens
|
||||
} else if cachedTokens, ok := extractCachedTokensFromBody(responseBody); ok {
|
||||
usage.PromptTokensDetails.CachedTokens = cachedTokens
|
||||
} else if usage.PromptCacheHitTokens > 0 {
|
||||
usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens
|
||||
}
|
||||
}
|
||||
case constant.ChannelTypeOpenAI:
|
||||
if usage.PromptTokensDetails.CachedTokens == 0 {
|
||||
if cachedTokens, ok := extractLlamaCachedTokensFromBody(responseBody); ok {
|
||||
usage.PromptTokensDetails.CachedTokens = cachedTokens
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractCachedTokensFromBody(body []byte) (int, bool) {
|
||||
if len(body) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Usage struct {
|
||||
PromptTokensDetails struct {
|
||||
CachedTokens *int `json:"cached_tokens"`
|
||||
} `json:"prompt_tokens_details"`
|
||||
CachedTokens *int `json:"cached_tokens"`
|
||||
PromptCacheHitTokens *int `json:"prompt_cache_hit_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
if err := common.Unmarshal(body, &payload); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if payload.Usage.PromptTokensDetails.CachedTokens != nil {
|
||||
return *payload.Usage.PromptTokensDetails.CachedTokens, true
|
||||
}
|
||||
if payload.Usage.CachedTokens != nil {
|
||||
return *payload.Usage.CachedTokens, true
|
||||
}
|
||||
if payload.Usage.PromptCacheHitTokens != nil {
|
||||
return *payload.Usage.PromptCacheHitTokens, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// extractMoonshotCachedTokensFromBody 从Moonshot的非标准位置提取cached_tokens
|
||||
// Moonshot的流式响应格式: {"choices":[{"usage":{"cached_tokens":111}}]}
|
||||
func extractMoonshotCachedTokensFromBody(body []byte) (int, bool) {
|
||||
if len(body) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Choices []struct {
|
||||
Usage struct {
|
||||
CachedTokens *int `json:"cached_tokens"`
|
||||
} `json:"usage"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
if err := common.Unmarshal(body, &payload); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// 遍历choices查找cached_tokens
|
||||
for _, choice := range payload.Choices {
|
||||
if choice.Usage.CachedTokens != nil && *choice.Usage.CachedTokens > 0 {
|
||||
return *choice.Usage.CachedTokens, true
|
||||
}
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// extractLlamaCachedTokensFromBody 从llama.cpp的非标准位置提取cache_n
|
||||
func extractLlamaCachedTokensFromBody(body []byte) (int, bool) {
|
||||
if len(body) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Timings struct {
|
||||
CachedTokens *int `json:"cache_n"`
|
||||
} `json:"timings"`
|
||||
}
|
||||
|
||||
if err := common.Unmarshal(body, &payload); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if payload.Timings.CachedTokens == nil {
|
||||
return 0, false
|
||||
}
|
||||
return *payload.Timings.CachedTokens, true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
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"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// OpenaiImageHandler handles non-streaming OpenAI image responses
|
||||
// (generations/edits), returning the parsed usage for billing.
|
||||
func OpenaiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
defer service.CloseResponseBodyGracefully(resp)
|
||||
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
var usageResp dto.SimpleResponse
|
||||
err = common.Unmarshal(responseBody, &usageResp)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if oaiError := usageResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
|
||||
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
|
||||
}
|
||||
|
||||
// 写入新的 response body
|
||||
service.IOCopyBytesGracefully(c, resp, responseBody)
|
||||
|
||||
normalizeOpenAIUsage(&usageResp.Usage)
|
||||
applyUsagePostProcessing(info, &usageResp.Usage, responseBody)
|
||||
return &usageResp.Usage, nil
|
||||
}
|
||||
|
||||
// normalizeOpenAIUsage maps the OpenAI Images usage shape (input_tokens /
|
||||
// output_tokens / input_tokens_details) onto the canonical prompt/completion
|
||||
// fields. It is used only on the OpenAI image relay paths (generations/edits,
|
||||
// streaming and non-streaming): the image API never returns prompt_tokens /
|
||||
// completion_tokens, so the overwrite (=) semantics here are equivalent to the
|
||||
// previous additive (+=) behavior while avoiding any future double-counting if
|
||||
// both field sets are ever populated. Do not reuse this on chat/embedding paths
|
||||
// without revisiting the overwrite semantics.
|
||||
func normalizeOpenAIUsage(usage *dto.Usage) {
|
||||
if usage == nil {
|
||||
return
|
||||
}
|
||||
if usage.InputTokens != 0 {
|
||||
usage.PromptTokens = usage.InputTokens
|
||||
}
|
||||
if usage.OutputTokens != 0 {
|
||||
usage.CompletionTokens = usage.OutputTokens
|
||||
}
|
||||
if usage.InputTokensDetails != nil {
|
||||
usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens
|
||||
usage.PromptTokensDetails.CachedCreationTokens = usage.InputTokensDetails.CachedCreationTokens
|
||||
usage.PromptTokensDetails.ImageTokens = usage.InputTokensDetails.ImageTokens
|
||||
usage.PromptTokensDetails.TextTokens = usage.InputTokensDetails.TextTokens
|
||||
usage.PromptTokensDetails.AudioTokens = usage.InputTokensDetails.AudioTokens
|
||||
}
|
||||
if usage.TotalTokens == 0 {
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
}
|
||||
}
|
||||
|
||||
func OpenaiImageStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
if resp == nil || resp.Body == nil {
|
||||
logger.LogError(c, "invalid image stream response")
|
||||
return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
contentType := strings.ToLower(resp.Header.Get("Content-Type"))
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return OpenaiImageHandler(c, info, resp)
|
||||
}
|
||||
if !strings.Contains(contentType, "text/event-stream") {
|
||||
return OpenaiImageJSONAsStreamHandler(c, info, resp)
|
||||
}
|
||||
// Reuse the shared streaming engine (helper.StreamScannerHandler) so the
|
||||
// image streaming path gets the same ping keepalive, streaming-timeout
|
||||
// watchdog, client-disconnect detection, panic recovery and goroutine
|
||||
// cleanup as every other relay stream. The scanner delivers only the
|
||||
// "data:" payload, so the SSE "event:" line is rebuilt from the JSON "type"
|
||||
// field (real OpenAI image events keep event == type).
|
||||
usage := &dto.Usage{}
|
||||
var lastStreamData []byte
|
||||
|
||||
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
|
||||
raw := common.StringToByteSlice(data)
|
||||
lastStreamData = raw
|
||||
if isOpenAIImageStreamErrorEvent(raw) {
|
||||
// Record the error as a soft error; the scanner drives the final
|
||||
// EndReason. HasErrors() flags the failure for logging/handling.
|
||||
sr.Error(fmt.Errorf("%s", extractOpenAIImageStreamErrorMessage(raw)))
|
||||
}
|
||||
var usageResp dto.SimpleResponse
|
||||
if err := common.Unmarshal(raw, &usageResp); err == nil {
|
||||
normalizeOpenAIUsage(&usageResp.Usage)
|
||||
if service.ValidUsage(&usageResp.Usage) {
|
||||
usage = &usageResp.Usage
|
||||
}
|
||||
}
|
||||
writeOpenaiImageStreamChunk(c, raw)
|
||||
})
|
||||
|
||||
// StreamScannerHandler consumes the upstream [DONE]; re-emit it so the
|
||||
// client still receives a terminal data: [DONE].
|
||||
if info != nil && info.StreamStatus != nil && info.StreamStatus.EndReason == relaycommon.StreamEndReasonDone {
|
||||
helper.Done(c)
|
||||
}
|
||||
|
||||
applyUsagePostProcessing(info, usage, lastStreamData)
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
// writeOpenaiImageStreamChunk rebuilds the SSE frame for an image stream chunk:
|
||||
// it emits an "event:" line derived from the JSON "type" field (when present)
|
||||
// followed by the verbatim "data:" payload, mirroring helper.ResponseChunkData.
|
||||
func writeOpenaiImageStreamChunk(c *gin.Context, data []byte) {
|
||||
var payload struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
_ = common.Unmarshal(data, &payload)
|
||||
if eventName := strings.TrimSpace(payload.Type); eventName != "" {
|
||||
c.Render(-1, common.CustomEvent{Data: fmt.Sprintf("event: %s\n", eventName)})
|
||||
}
|
||||
c.Render(-1, common.CustomEvent{Data: "data: " + string(data)})
|
||||
_ = helper.FlushWriter(c)
|
||||
}
|
||||
|
||||
// isOpenAIImageStreamErrorEvent detects upstream error chunks by JSON content
|
||||
// only ("type" of error/upstream_error, or a non-empty "error" field). The SSE
|
||||
// "event:" line is not available here: StreamScannerHandler delivers only the
|
||||
// "data:" payload. A payload carrying just a "message" key is deliberately NOT
|
||||
// treated as an error to avoid false positives.
|
||||
func isOpenAIImageStreamErrorEvent(data []byte) bool {
|
||||
if !json.Valid(data) {
|
||||
return false
|
||||
}
|
||||
var payload struct {
|
||||
Type string `json:"type"`
|
||||
Error json.RawMessage `json:"error"`
|
||||
}
|
||||
if err := common.Unmarshal(data, &payload); err != nil {
|
||||
return false
|
||||
}
|
||||
payloadType := strings.ToLower(strings.TrimSpace(payload.Type))
|
||||
return payloadType == "error" || payloadType == "upstream_error" || len(payload.Error) > 0
|
||||
}
|
||||
|
||||
func extractOpenAIImageStreamErrorMessage(data []byte) string {
|
||||
if len(data) == 0 || !json.Valid(data) {
|
||||
return "upstream image stream returned error event"
|
||||
}
|
||||
var payload struct {
|
||||
Message string `json:"message"`
|
||||
Error json.RawMessage `json:"error"`
|
||||
}
|
||||
if err := common.Unmarshal(data, &payload); err != nil {
|
||||
return "upstream image stream returned error event"
|
||||
}
|
||||
if msg := strings.TrimSpace(payload.Message); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if len(payload.Error) > 0 {
|
||||
var nested struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := common.Unmarshal(payload.Error, &nested); err == nil {
|
||||
if msg := strings.TrimSpace(nested.Message); msg != "" {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
if msg := strings.TrimSpace(common.JsonRawMessageToString(payload.Error)); msg != "" {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
return "upstream image stream returned error event"
|
||||
}
|
||||
|
||||
func OpenaiImageJSONAsStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||
defer service.CloseResponseBodyGracefully(resp)
|
||||
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
var imageResp dto.ImageResponse
|
||||
if err := common.Unmarshal(responseBody, &imageResp); err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
var usageResp dto.SimpleResponse
|
||||
_ = common.Unmarshal(responseBody, &usageResp)
|
||||
if oaiError := usageResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
|
||||
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
|
||||
}
|
||||
normalizeOpenAIUsage(&usageResp.Usage)
|
||||
applyUsagePostProcessing(info, &usageResp.Usage, responseBody)
|
||||
|
||||
helper.SetEventStreamHeaders(c)
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
created := imageResp.Created
|
||||
if created == 0 {
|
||||
created = time.Now().Unix()
|
||||
}
|
||||
if info != nil {
|
||||
info.SetFirstResponseTime()
|
||||
}
|
||||
for _, image := range imageResp.Data {
|
||||
payload := map[string]any{
|
||||
"type": "image_generation.completed",
|
||||
"created_at": created,
|
||||
}
|
||||
if image.Url != "" {
|
||||
payload["url"] = image.Url
|
||||
}
|
||||
if image.B64Json != "" {
|
||||
payload["b64_json"] = image.B64Json
|
||||
}
|
||||
if image.RevisedPrompt != "" {
|
||||
payload["revised_prompt"] = image.RevisedPrompt
|
||||
}
|
||||
if service.ValidUsage(&usageResp.Usage) {
|
||||
payload["usage"] = usageResp.Usage
|
||||
}
|
||||
if err := writeOpenaiImageStreamPayload(c, "image_generation.completed", payload); err != nil {
|
||||
if info != nil && info.StreamStatus != nil {
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonClientGone, err)
|
||||
}
|
||||
return &usageResp.Usage, nil
|
||||
}
|
||||
}
|
||||
if err := writeOpenaiImageStreamDone(c); err != nil {
|
||||
if info != nil && info.StreamStatus != nil {
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonClientGone, err)
|
||||
}
|
||||
return &usageResp.Usage, nil
|
||||
}
|
||||
if info != nil {
|
||||
info.ReceivedResponseCount += len(imageResp.Data)
|
||||
if info.StreamStatus == nil {
|
||||
info.StreamStatus = relaycommon.NewStreamStatus()
|
||||
}
|
||||
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonDone, nil)
|
||||
}
|
||||
return &usageResp.Usage, nil
|
||||
}
|
||||
|
||||
func writeOpenaiImageStreamPayload(c *gin.Context, eventName string, payload any) error {
|
||||
data, err := common.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if eventName != "" {
|
||||
if _, err := fmt.Fprintf(c.Writer, "event: %s\n", eventName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", data); err != nil {
|
||||
return err
|
||||
}
|
||||
return helper.FlushWriter(c)
|
||||
}
|
||||
|
||||
func writeOpenaiImageStreamDone(c *gin.Context) error {
|
||||
if _, err := fmt.Fprint(c.Writer, "data: [DONE]\n\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
return helper.FlushWriter(c)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
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"
|
||||
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.RealtimeUsage) {
|
||||
if info == nil || info.ClientWs == nil || info.TargetWs == nil {
|
||||
return types.NewError(fmt.Errorf("invalid websocket connection"), types.ErrorCodeBadResponse), nil
|
||||
}
|
||||
|
||||
info.IsStream = true
|
||||
clientConn := info.ClientWs
|
||||
targetConn := info.TargetWs
|
||||
|
||||
clientClosed := make(chan struct{})
|
||||
targetClosed := make(chan struct{})
|
||||
sendChan := make(chan []byte, 100)
|
||||
receiveChan := make(chan []byte, 100)
|
||||
errChan := make(chan error, 2)
|
||||
|
||||
usage := &dto.RealtimeUsage{}
|
||||
localUsage := &dto.RealtimeUsage{}
|
||||
sumUsage := &dto.RealtimeUsage{}
|
||||
|
||||
gopool.Go(func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
errChan <- fmt.Errorf("panic in client reader: %v", r)
|
||||
}
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case <-c.Done():
|
||||
return
|
||||
default:
|
||||
_, message, err := clientConn.ReadMessage()
|
||||
if err != nil {
|
||||
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
||||
errChan <- fmt.Errorf("error reading from client: %v", err)
|
||||
}
|
||||
close(clientClosed)
|
||||
return
|
||||
}
|
||||
|
||||
realtimeEvent := &dto.RealtimeEvent{}
|
||||
err = common.Unmarshal(message, realtimeEvent)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error unmarshalling message: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if realtimeEvent.Type == dto.RealtimeEventTypeSessionUpdate {
|
||||
if realtimeEvent.Session != nil {
|
||||
if realtimeEvent.Session.Tools != nil {
|
||||
info.RealtimeTools = realtimeEvent.Session.Tools
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
textToken, audioToken, err := service.CountTokenRealtime(info, *realtimeEvent, info.UpstreamModelName)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error counting text token: %v", err)
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
|
||||
localUsage.TotalTokens += textToken + audioToken
|
||||
localUsage.InputTokens += textToken + audioToken
|
||||
localUsage.InputTokenDetails.TextTokens += textToken
|
||||
localUsage.InputTokenDetails.AudioTokens += audioToken
|
||||
|
||||
err = helper.WssString(c, targetConn, string(message))
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error writing to target: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case sendChan <- message:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
gopool.Go(func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
errChan <- fmt.Errorf("panic in target reader: %v", r)
|
||||
}
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case <-c.Done():
|
||||
return
|
||||
default:
|
||||
_, message, err := targetConn.ReadMessage()
|
||||
if err != nil {
|
||||
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
||||
errChan <- fmt.Errorf("error reading from target: %v", err)
|
||||
}
|
||||
close(targetClosed)
|
||||
return
|
||||
}
|
||||
info.SetFirstResponseTime()
|
||||
realtimeEvent := &dto.RealtimeEvent{}
|
||||
err = common.Unmarshal(message, realtimeEvent)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error unmarshalling message: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if realtimeEvent.Type == dto.RealtimeEventTypeResponseDone {
|
||||
realtimeUsage := realtimeEvent.Response.Usage
|
||||
if realtimeUsage != nil {
|
||||
usage.TotalTokens += realtimeUsage.TotalTokens
|
||||
usage.InputTokens += realtimeUsage.InputTokens
|
||||
usage.OutputTokens += realtimeUsage.OutputTokens
|
||||
usage.InputTokenDetails.AudioTokens += realtimeUsage.InputTokenDetails.AudioTokens
|
||||
usage.InputTokenDetails.CachedTokens += realtimeUsage.InputTokenDetails.CachedTokens
|
||||
usage.InputTokenDetails.TextTokens += realtimeUsage.InputTokenDetails.TextTokens
|
||||
usage.OutputTokenDetails.AudioTokens += realtimeUsage.OutputTokenDetails.AudioTokens
|
||||
usage.OutputTokenDetails.TextTokens += realtimeUsage.OutputTokenDetails.TextTokens
|
||||
err := preConsumeUsage(c, info, usage, sumUsage)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error consume usage: %v", err)
|
||||
return
|
||||
}
|
||||
// 本次计费完成,清除
|
||||
usage = &dto.RealtimeUsage{}
|
||||
|
||||
localUsage = &dto.RealtimeUsage{}
|
||||
} else {
|
||||
textToken, audioToken, err := service.CountTokenRealtime(info, *realtimeEvent, info.UpstreamModelName)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error counting text token: %v", err)
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
|
||||
localUsage.TotalTokens += textToken + audioToken
|
||||
info.IsFirstRequest = false
|
||||
localUsage.InputTokens += textToken + audioToken
|
||||
localUsage.InputTokenDetails.TextTokens += textToken
|
||||
localUsage.InputTokenDetails.AudioTokens += audioToken
|
||||
err = preConsumeUsage(c, info, localUsage, sumUsage)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error consume usage: %v", err)
|
||||
return
|
||||
}
|
||||
// 本次计费完成,清除
|
||||
localUsage = &dto.RealtimeUsage{}
|
||||
// print now usage
|
||||
}
|
||||
logger.LogInfo(c, fmt.Sprintf("realtime streaming sumUsage: %v", sumUsage))
|
||||
logger.LogInfo(c, fmt.Sprintf("realtime streaming localUsage: %v", localUsage))
|
||||
logger.LogInfo(c, fmt.Sprintf("realtime streaming localUsage: %v", localUsage))
|
||||
|
||||
} else if realtimeEvent.Type == dto.RealtimeEventTypeSessionUpdated || realtimeEvent.Type == dto.RealtimeEventTypeSessionCreated {
|
||||
realtimeSession := realtimeEvent.Session
|
||||
if realtimeSession != nil {
|
||||
// update audio format
|
||||
info.InputAudioFormat = common.GetStringIfEmpty(realtimeSession.InputAudioFormat, info.InputAudioFormat)
|
||||
info.OutputAudioFormat = common.GetStringIfEmpty(realtimeSession.OutputAudioFormat, info.OutputAudioFormat)
|
||||
}
|
||||
} else {
|
||||
textToken, audioToken, err := service.CountTokenRealtime(info, *realtimeEvent, info.UpstreamModelName)
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error counting text token: %v", err)
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken))
|
||||
localUsage.TotalTokens += textToken + audioToken
|
||||
localUsage.OutputTokens += textToken + audioToken
|
||||
localUsage.OutputTokenDetails.TextTokens += textToken
|
||||
localUsage.OutputTokenDetails.AudioTokens += audioToken
|
||||
}
|
||||
|
||||
err = helper.WssString(c, clientConn, string(message))
|
||||
if err != nil {
|
||||
errChan <- fmt.Errorf("error writing to client: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case receiveChan <- message:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
select {
|
||||
case <-clientClosed:
|
||||
case <-targetClosed:
|
||||
case err := <-errChan:
|
||||
//return service.OpenAIErrorWrapper(err, "realtime_error", http.StatusInternalServerError), nil
|
||||
logger.LogError(c, "realtime error: "+err.Error())
|
||||
case <-c.Done():
|
||||
}
|
||||
|
||||
if usage.TotalTokens != 0 {
|
||||
_ = preConsumeUsage(c, info, usage, sumUsage)
|
||||
}
|
||||
|
||||
if localUsage.TotalTokens != 0 {
|
||||
_ = preConsumeUsage(c, info, localUsage, sumUsage)
|
||||
}
|
||||
|
||||
// check usage total tokens, if 0, use local usage
|
||||
|
||||
return nil, sumUsage
|
||||
}
|
||||
|
||||
func preConsumeUsage(ctx *gin.Context, info *relaycommon.RelayInfo, usage *dto.RealtimeUsage, totalUsage *dto.RealtimeUsage) error {
|
||||
if usage == nil || totalUsage == nil {
|
||||
return fmt.Errorf("invalid usage pointer")
|
||||
}
|
||||
|
||||
totalUsage.TotalTokens += usage.TotalTokens
|
||||
totalUsage.InputTokens += usage.InputTokens
|
||||
totalUsage.OutputTokens += usage.OutputTokens
|
||||
totalUsage.InputTokenDetails.CachedTokens += usage.InputTokenDetails.CachedTokens
|
||||
totalUsage.InputTokenDetails.TextTokens += usage.InputTokenDetails.TextTokens
|
||||
totalUsage.InputTokenDetails.AudioTokens += usage.InputTokenDetails.AudioTokens
|
||||
totalUsage.OutputTokenDetails.TextTokens += usage.OutputTokenDetails.TextTokens
|
||||
totalUsage.OutputTokenDetails.AudioTokens += usage.OutputTokenDetails.AudioTokens
|
||||
// clear usage
|
||||
err := service.PreWssConsumeQuota(ctx, info, usage)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
)
|
||||
|
||||
func applyUsagePostProcessing(info *relaycommon.RelayInfo, usage *dto.Usage, responseBody []byte) {
|
||||
if info == nil || usage == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch info.ChannelType {
|
||||
case constant.ChannelTypeDeepSeek:
|
||||
if usage.PromptTokensDetails.CachedTokens == 0 && usage.PromptCacheHitTokens != 0 {
|
||||
usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens
|
||||
}
|
||||
case constant.ChannelTypeZhipu_v4:
|
||||
// 智普的cached_tokens在标准位置: usage.prompt_tokens_details.cached_tokens
|
||||
if usage.PromptTokensDetails.CachedTokens == 0 {
|
||||
if usage.InputTokensDetails != nil && usage.InputTokensDetails.CachedTokens > 0 {
|
||||
usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens
|
||||
} else if cachedTokens, ok := extractCachedTokensFromBody(responseBody); ok {
|
||||
usage.PromptTokensDetails.CachedTokens = cachedTokens
|
||||
} else if usage.PromptCacheHitTokens > 0 {
|
||||
usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens
|
||||
}
|
||||
}
|
||||
case constant.ChannelTypeMoonshot:
|
||||
// Moonshot的cached_tokens在非标准位置: choices[].usage.cached_tokens
|
||||
if usage.PromptTokensDetails.CachedTokens == 0 {
|
||||
if usage.InputTokensDetails != nil && usage.InputTokensDetails.CachedTokens > 0 {
|
||||
usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens
|
||||
} else if cachedTokens, ok := extractMoonshotCachedTokensFromBody(responseBody); ok {
|
||||
usage.PromptTokensDetails.CachedTokens = cachedTokens
|
||||
} else if cachedTokens, ok := extractCachedTokensFromBody(responseBody); ok {
|
||||
usage.PromptTokensDetails.CachedTokens = cachedTokens
|
||||
} else if usage.PromptCacheHitTokens > 0 {
|
||||
usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens
|
||||
}
|
||||
}
|
||||
case constant.ChannelTypeOpenAI:
|
||||
if usage.PromptTokensDetails.CachedTokens == 0 {
|
||||
if cachedTokens, ok := extractLlamaCachedTokensFromBody(responseBody); ok {
|
||||
usage.PromptTokensDetails.CachedTokens = cachedTokens
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractCachedTokensFromBody(body []byte) (int, bool) {
|
||||
if len(body) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Usage struct {
|
||||
PromptTokensDetails struct {
|
||||
CachedTokens *int `json:"cached_tokens"`
|
||||
} `json:"prompt_tokens_details"`
|
||||
CachedTokens *int `json:"cached_tokens"`
|
||||
PromptCacheHitTokens *int `json:"prompt_cache_hit_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
if err := common.Unmarshal(body, &payload); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if payload.Usage.PromptTokensDetails.CachedTokens != nil {
|
||||
return *payload.Usage.PromptTokensDetails.CachedTokens, true
|
||||
}
|
||||
if payload.Usage.CachedTokens != nil {
|
||||
return *payload.Usage.CachedTokens, true
|
||||
}
|
||||
if payload.Usage.PromptCacheHitTokens != nil {
|
||||
return *payload.Usage.PromptCacheHitTokens, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// extractMoonshotCachedTokensFromBody 从Moonshot的非标准位置提取cached_tokens
|
||||
// Moonshot的流式响应格式: {"choices":[{"usage":{"cached_tokens":111}}]}
|
||||
func extractMoonshotCachedTokensFromBody(body []byte) (int, bool) {
|
||||
if len(body) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Choices []struct {
|
||||
Usage struct {
|
||||
CachedTokens *int `json:"cached_tokens"`
|
||||
} `json:"usage"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
if err := common.Unmarshal(body, &payload); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// 遍历choices查找cached_tokens
|
||||
for _, choice := range payload.Choices {
|
||||
if choice.Usage.CachedTokens != nil && *choice.Usage.CachedTokens > 0 {
|
||||
return *choice.Usage.CachedTokens, true
|
||||
}
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// extractLlamaCachedTokensFromBody 从llama.cpp的非标准位置提取cache_n
|
||||
func extractLlamaCachedTokensFromBody(body []byte) (int, bool) {
|
||||
if len(body) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Timings struct {
|
||||
CachedTokens *int `json:"cache_n"`
|
||||
} `json:"timings"`
|
||||
}
|
||||
|
||||
if err := common.Unmarshal(body, &payload); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if payload.Timings.CachedTokens == nil {
|
||||
return 0, false
|
||||
}
|
||||
return *payload.Timings.CachedTokens, true
|
||||
}
|
||||
@@ -114,7 +114,7 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
|
||||
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
|
||||
switch info.RelayMode {
|
||||
case constant.RelayModeImagesGenerations, constant.RelayModeImagesEdits:
|
||||
usage, err = openai.OpenaiHandlerWithUsage(c, info, resp)
|
||||
usage, err = openai.OpenaiImageHandler(c, info, resp)
|
||||
case constant.RelayModeResponses:
|
||||
if info.IsStream {
|
||||
usage, err = openai.OaiResponsesStreamHandler(c, info, resp)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestGetAndValidOpenAIImageRequestMultipartStream verifies multipart image
|
||||
// edit parsing: the stream field is parsed and validated, and the request body
|
||||
// stays replayable for the upstream request.
|
||||
func TestGetAndValidOpenAIImageRequestMultipartStream(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
newContext := func(t *testing.T, streamValue string, withImage bool) (*gin.Context, string) {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
require.NoError(t, writer.WriteField("model", "gpt-image-1"))
|
||||
require.NoError(t, writer.WriteField("prompt", "edit this image"))
|
||||
require.NoError(t, writer.WriteField("stream", streamValue))
|
||||
if withImage {
|
||||
part, err := writer.CreateFormFile("image", "input.png")
|
||||
require.NoError(t, err)
|
||||
_, err = part.Write([]byte("fake image"))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, writer.Close())
|
||||
originalBody := body.String()
|
||||
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body)
|
||||
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
return c, originalBody
|
||||
}
|
||||
|
||||
t.Run("valid stream value keeps body replayable", func(t *testing.T) {
|
||||
c, originalBody := newContext(t, "true", true)
|
||||
|
||||
req, err := GetAndValidOpenAIImageRequest(c, relayconstant.RelayModeImagesEdits)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, req.Stream)
|
||||
require.True(t, *req.Stream)
|
||||
require.True(t, req.IsStream(c))
|
||||
|
||||
bodyAfterValidation, err := io.ReadAll(c.Request.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, originalBody, string(bodyAfterValidation))
|
||||
|
||||
form, err := common.ParseMultipartFormReusable(c)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "true", url.Values(form.Value).Get("stream"))
|
||||
require.Len(t, form.File["image"], 1)
|
||||
})
|
||||
|
||||
t.Run("invalid stream value is rejected", func(t *testing.T) {
|
||||
c, _ := newContext(t, "notabool", false)
|
||||
|
||||
_, err := GetAndValidOpenAIImageRequest(c, relayconstant.RelayModeImagesEdits)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "invalid stream value")
|
||||
})
|
||||
}
|
||||
@@ -22,8 +22,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
InitialScannerBufferSize = 64 << 10 // 64KB (64*1024)
|
||||
DefaultMaxScannerBufferSize = 64 << 20 // 64MB (64*1024*1024) default SSE buffer size
|
||||
InitialScannerBufferSize = 64 << 10 // 64KB (64*1024)
|
||||
DefaultMaxScannerBufferSize = 128 << 20 // 64MB (64*1024*1024) default SSE buffer size
|
||||
DefaultPingInterval = 10 * time.Second
|
||||
)
|
||||
|
||||
|
||||
@@ -631,7 +631,7 @@ func TestStreamScannerHandler_StreamStatus_InitializedIfNil(t *testing.T) {
|
||||
assert.NotNil(t, info.StreamStatus)
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_StreamStatus_PreInitialized(t *testing.T) {
|
||||
func TestStreamScannerHandler_StreamStatus_ReplacesPreInitialized(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := buildSSEBody(5)
|
||||
@@ -643,7 +643,7 @@ func TestStreamScannerHandler_StreamStatus_PreInitialized(t *testing.T) {
|
||||
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {})
|
||||
|
||||
assert.Equal(t, relaycommon.StreamEndReasonDone, info.StreamStatus.EndReason)
|
||||
assert.Equal(t, 1, info.StreamStatus.TotalErrorCount())
|
||||
assert.Equal(t, 0, info.StreamStatus.TotalErrorCount())
|
||||
}
|
||||
|
||||
func TestStreamScannerHandler_PingInterleavesWithSlowUpstream(t *testing.T) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -144,16 +146,25 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq
|
||||
switch relayMode {
|
||||
case relayconstant.RelayModeImagesEdits:
|
||||
if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
|
||||
_, err := c.MultipartForm()
|
||||
form, err := common.ParseMultipartFormReusable(c)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse image edit form request: %w", err)
|
||||
}
|
||||
formData := c.Request.PostForm
|
||||
formData := url.Values(form.Value)
|
||||
c.Request.MultipartForm = form
|
||||
c.Request.PostForm = formData
|
||||
imageRequest.Prompt = formData.Get("prompt")
|
||||
imageRequest.Model = formData.Get("model")
|
||||
imageRequest.N = common.GetPointer(uint(common.String2Int(formData.Get("n"))))
|
||||
imageRequest.Quality = formData.Get("quality")
|
||||
imageRequest.Size = formData.Get("size")
|
||||
if streamValue := strings.TrimSpace(formData.Get("stream")); streamValue != "" {
|
||||
stream, err := strconv.ParseBool(streamValue)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid stream value: %w", err)
|
||||
}
|
||||
imageRequest.Stream = common.GetPointer(stream)
|
||||
}
|
||||
if imageValue := formData.Get("image"); imageValue != "" {
|
||||
imageRequest.Image, _ = common.Marshal(imageValue)
|
||||
}
|
||||
|
||||
+1
-2
@@ -27,7 +27,6 @@ import {
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type { Element } from 'hast'
|
||||
import { CheckIcon, CopyIcon } from 'lucide-react'
|
||||
import {
|
||||
type BundledLanguage,
|
||||
@@ -53,7 +52,7 @@ const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
|
||||
const lineNumberTransformer: ShikiTransformer = {
|
||||
name: 'line-numbers',
|
||||
line(node: Element, line: number) {
|
||||
line(node, line) {
|
||||
node.children.unshift({
|
||||
type: 'element',
|
||||
tagName: 'span',
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
# Data Table Components
|
||||
|
||||
This package keeps a stable public API through `index.ts`; feature code should
|
||||
continue importing from `@/components/data-table`.
|
||||
|
||||
- `core/`: TanStack table rendering primitives, headers, rows, pagination,
|
||||
loading, empty states, and pinned-column behavior.
|
||||
- `layout/`: responsive page-level composition that combines toolbar, desktop
|
||||
table, mobile list, bulk actions, and pagination placement.
|
||||
- `toolbar/`: filter/search/view-option controls and selection action toolbar.
|
||||
- `static/`: lightweight table rendering for local/static arrays that do not
|
||||
need TanStack state.
|
||||
- `hooks/`: table state and filter hooks.
|
||||
|
||||
Keep feature-specific columns, actions, and dialogs inside their feature
|
||||
folders. Shared table code belongs here only when it is reusable across more
|
||||
than one feature.
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
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 { cn } from '@/lib/utils'
|
||||
import type { DataTableColumnClassName, DataTablePinnedColumn } from './types'
|
||||
|
||||
export function getResolvedColumnClassName(
|
||||
getColumnClassName?: DataTableColumnClassName,
|
||||
pinnedColumns?: DataTablePinnedColumn[]
|
||||
): DataTableColumnClassName {
|
||||
return getResolvedColumnClassNameFromMap(
|
||||
getColumnClassName,
|
||||
getPinnedColumnMap(pinnedColumns)
|
||||
)
|
||||
}
|
||||
|
||||
export function getResolvedColumnClassNameFromMap(
|
||||
getColumnClassName?: DataTableColumnClassName,
|
||||
pinnedColumnById?: Map<string, DataTablePinnedColumn>
|
||||
): DataTableColumnClassName {
|
||||
return (columnId, kind) => {
|
||||
const customClassName = getColumnClassName?.(columnId, kind)
|
||||
const pinnedColumn = pinnedColumnById?.get(columnId)
|
||||
|
||||
if (!pinnedColumn) return customClassName
|
||||
|
||||
return cn(customClassName, getPinnedColumnClassName(pinnedColumn, kind))
|
||||
}
|
||||
}
|
||||
|
||||
export function getPinnedColumnMap(pinnedColumns?: DataTablePinnedColumn[]) {
|
||||
if (!pinnedColumns?.length) return undefined
|
||||
|
||||
return new Map(pinnedColumns.map((column) => [column.columnId, column]))
|
||||
}
|
||||
|
||||
function getPinnedColumnClassName(
|
||||
pinnedColumn: DataTablePinnedColumn,
|
||||
kind: 'header' | 'cell'
|
||||
) {
|
||||
const edgeClassName =
|
||||
pinnedColumn.side === 'left'
|
||||
? 'shadow-[8px_0_10px_-10px_hsl(var(--foreground))]'
|
||||
: 'shadow-[-8px_0_10px_-10px_hsl(var(--foreground))]'
|
||||
|
||||
return cn(
|
||||
'sticky whitespace-nowrap',
|
||||
pinnedColumn.side === 'left' ? 'left-0' : 'right-0',
|
||||
edgeClassName,
|
||||
kind === 'header'
|
||||
? 'bg-background z-30'
|
||||
: 'bg-background z-10 group-hover:bg-muted group-data-[state=selected]:bg-muted',
|
||||
pinnedColumn.className,
|
||||
kind === 'header'
|
||||
? pinnedColumn.headerClassName
|
||||
: pinnedColumn.cellClassName
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
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 type { Table as TanstackTable } from '@tanstack/react-table'
|
||||
|
||||
export function DataTableColgroup<TData>({
|
||||
table,
|
||||
}: {
|
||||
table: TanstackTable<TData>
|
||||
}) {
|
||||
return (
|
||||
<colgroup>
|
||||
{table.getVisibleLeafColumns().map((column) => (
|
||||
<col key={column.id} style={{ width: column.getSize() }} />
|
||||
))}
|
||||
</colgroup>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
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 { flexRender, type Table as TanstackTable } from '@tanstack/react-table'
|
||||
import { TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import type { DataTableColumnClassName } from './types'
|
||||
|
||||
type DataTableHeaderProps<TData> = {
|
||||
table: TanstackTable<TData>
|
||||
applyHeaderSize?: boolean
|
||||
className?: string
|
||||
rowClassName?: string
|
||||
getColumnClassName?: DataTableColumnClassName
|
||||
}
|
||||
|
||||
export function DataTableHeader<TData>({
|
||||
table,
|
||||
applyHeaderSize,
|
||||
className,
|
||||
rowClassName,
|
||||
getColumnClassName,
|
||||
}: DataTableHeaderProps<TData>) {
|
||||
return (
|
||||
<TableHeader className={className}>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className={rowClassName}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
className={getColumnClassName?.(header.column.id, 'header')}
|
||||
style={applyHeaderSize ? { width: header.getSize() } : undefined}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
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 type * as React from 'react'
|
||||
import { flexRender, type Row } from '@tanstack/react-table'
|
||||
import { TableCell, TableRow } from '@/components/ui/table'
|
||||
import type { DataTableColumnClassName } from './types'
|
||||
|
||||
type DataTableRowProps<TData> = {
|
||||
row: Row<TData>
|
||||
className?: string
|
||||
getColumnClassName?: DataTableColumnClassName
|
||||
} & Omit<React.ComponentProps<typeof TableRow>, 'children'>
|
||||
|
||||
export function DataTableRow<TData>({
|
||||
row,
|
||||
className,
|
||||
getColumnClassName,
|
||||
...rowProps
|
||||
}: DataTableRowProps<TData>) {
|
||||
return (
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() ? 'selected' : undefined}
|
||||
className={className}
|
||||
{...rowProps}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={getColumnClassName?.(cell.column.id, 'cell')}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/*
|
||||
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 { type Row } from '@tanstack/react-table'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table'
|
||||
import {
|
||||
getPinnedColumnMap,
|
||||
getResolvedColumnClassNameFromMap,
|
||||
} from './column-pinning'
|
||||
import { DataTableColgroup } from './data-table-colgroup'
|
||||
import { DataTableHeader } from './data-table-header'
|
||||
import { DataTableRow } from './data-table-row'
|
||||
import { TableEmpty } from './table-empty'
|
||||
import { getTableSizeStyle } from './table-sizing'
|
||||
import { TableSkeleton } from './table-skeleton'
|
||||
import type {
|
||||
DataTableColumnClassName,
|
||||
DataTablePinnedColumn,
|
||||
DataTableViewProps,
|
||||
} from './types'
|
||||
|
||||
export type {
|
||||
DataTableColumnClassName,
|
||||
DataTablePinnedColumn,
|
||||
DataTableRenderRowHelpers,
|
||||
DataTableViewProps,
|
||||
} from './types'
|
||||
export { DataTableRow } from './data-table-row'
|
||||
|
||||
export function DataTableView<TData>(props: DataTableViewProps<TData>) {
|
||||
const rows = props.rows ?? props.table.getRowModel().rows
|
||||
const colSpan = props.table.getVisibleLeafColumns().length
|
||||
const columnClassName = useResolvedColumnClassName(
|
||||
props.getColumnClassName,
|
||||
props.pinnedColumns
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-lg border',
|
||||
props.containerClassName
|
||||
)}
|
||||
{...props.containerProps}
|
||||
>
|
||||
{props.splitHeader ? (
|
||||
<SplitHeaderTableView
|
||||
props={props}
|
||||
rows={rows}
|
||||
colSpan={colSpan}
|
||||
getColumnClassName={columnClassName}
|
||||
/>
|
||||
) : (
|
||||
<UnifiedTableView
|
||||
props={props}
|
||||
rows={rows}
|
||||
colSpan={colSpan}
|
||||
getColumnClassName={columnClassName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UnifiedTableView<TData>({
|
||||
props,
|
||||
rows,
|
||||
colSpan,
|
||||
getColumnClassName,
|
||||
}: {
|
||||
props: DataTableViewProps<TData>
|
||||
rows: Row<TData>[]
|
||||
colSpan: number
|
||||
getColumnClassName: DataTableColumnClassName
|
||||
}) {
|
||||
const tableSizing = getTableSizing(props)
|
||||
|
||||
return (
|
||||
<div className={props.tableContainerClassName}>
|
||||
<Table className={props.tableClassName} style={tableSizing.style}>
|
||||
{tableSizing.colgroup}
|
||||
<DataTableHeader
|
||||
table={props.table}
|
||||
applyHeaderSize={props.applyHeaderSize}
|
||||
className={props.tableHeaderClassName}
|
||||
rowClassName={props.tableHeaderRowClassName}
|
||||
getColumnClassName={getColumnClassName}
|
||||
/>
|
||||
{renderTableBody(props, rows, colSpan, getColumnClassName)}
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SplitHeaderTableView<TData>({
|
||||
props,
|
||||
rows,
|
||||
colSpan,
|
||||
getColumnClassName,
|
||||
}: {
|
||||
props: DataTableViewProps<TData>
|
||||
rows: Row<TData>[]
|
||||
colSpan: number
|
||||
getColumnClassName: DataTableColumnClassName
|
||||
}) {
|
||||
const headerHostRef = React.useRef<HTMLDivElement>(null)
|
||||
const bodyHostRef = React.useRef<HTMLDivElement>(null)
|
||||
const tableSizing = getTableSizing(props)
|
||||
|
||||
React.useEffect(() => {
|
||||
const headerScroller = headerHostRef.current?.querySelector<HTMLElement>(
|
||||
'[data-slot=table-container]'
|
||||
)
|
||||
const bodyScroller = bodyHostRef.current?.querySelector<HTMLElement>(
|
||||
'[data-slot=table-container]'
|
||||
)
|
||||
|
||||
if (!headerScroller || !bodyScroller) return
|
||||
|
||||
const syncHeaderScroll = () => {
|
||||
headerScroller.scrollLeft = bodyScroller.scrollLeft
|
||||
}
|
||||
|
||||
syncHeaderScroll()
|
||||
bodyScroller.addEventListener('scroll', syncHeaderScroll, { passive: true })
|
||||
|
||||
return () => {
|
||||
bodyScroller.removeEventListener('scroll', syncHeaderScroll)
|
||||
}
|
||||
}, [rows.length, props.tableClassName, props.colgroup])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full min-h-0 flex-col',
|
||||
props.tableContainerClassName
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col overflow-hidden',
|
||||
props.splitHeaderScrollClassName
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={headerHostRef}
|
||||
className='[scrollbar-gutter:stable] overflow-hidden [&_[data-slot=table-container]]:overflow-x-hidden'
|
||||
>
|
||||
<Table className={props.tableClassName} style={tableSizing.style}>
|
||||
{tableSizing.colgroup}
|
||||
<DataTableHeader
|
||||
table={props.table}
|
||||
applyHeaderSize={props.applyHeaderSize}
|
||||
className={props.tableHeaderClassName}
|
||||
rowClassName={props.tableHeaderRowClassName}
|
||||
getColumnClassName={getColumnClassName}
|
||||
/>
|
||||
</Table>
|
||||
</div>
|
||||
<div
|
||||
ref={bodyHostRef}
|
||||
className={cn(
|
||||
'min-h-0 flex-1 [scrollbar-gutter:stable] overflow-y-auto',
|
||||
props.bodyContainerClassName
|
||||
)}
|
||||
>
|
||||
<Table className={props.tableClassName} style={tableSizing.style}>
|
||||
{tableSizing.colgroup}
|
||||
{renderTableBody(props, rows, colSpan, getColumnClassName)}
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useResolvedColumnClassName(
|
||||
getColumnClassName?: DataTableColumnClassName,
|
||||
pinnedColumns?: DataTablePinnedColumn[]
|
||||
) {
|
||||
const pinnedColumnById = React.useMemo(
|
||||
() => getPinnedColumnMap(pinnedColumns),
|
||||
[pinnedColumns]
|
||||
)
|
||||
|
||||
return React.useMemo(
|
||||
() =>
|
||||
getResolvedColumnClassNameFromMap(getColumnClassName, pinnedColumnById),
|
||||
[getColumnClassName, pinnedColumnById]
|
||||
)
|
||||
}
|
||||
|
||||
function getTableSizing<TData>(props: DataTableViewProps<TData>): {
|
||||
colgroup?: React.ReactNode
|
||||
style?: React.CSSProperties
|
||||
} {
|
||||
if (props.colgroup) {
|
||||
return { colgroup: props.colgroup }
|
||||
}
|
||||
|
||||
if (!props.splitHeader && !props.applyHeaderSize) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
colgroup: <DataTableColgroup table={props.table} />,
|
||||
style: getTableSizeStyle(props.table),
|
||||
}
|
||||
}
|
||||
|
||||
function renderTableBody<TData>(
|
||||
props: DataTableViewProps<TData>,
|
||||
rows: Row<TData>[],
|
||||
colSpan: number,
|
||||
getColumnClassName: DataTableColumnClassName
|
||||
) {
|
||||
return (
|
||||
<TableBody className={props.tableBodyClassName}>
|
||||
{renderTableBodyContent(props, rows, colSpan, getColumnClassName)}
|
||||
</TableBody>
|
||||
)
|
||||
}
|
||||
|
||||
function renderTableBodyContent<TData>(
|
||||
props: DataTableViewProps<TData>,
|
||||
rows: Row<TData>[],
|
||||
colSpan: number,
|
||||
getColumnClassName: DataTableColumnClassName
|
||||
) {
|
||||
if (props.isLoading) {
|
||||
return (
|
||||
<TableSkeleton
|
||||
table={props.table}
|
||||
keyPrefix={props.skeletonKeyPrefix}
|
||||
rowHeight={props.skeletonRowHeight}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return renderEmptyState(props, colSpan)
|
||||
}
|
||||
|
||||
return rows.map((row) =>
|
||||
props.renderRow
|
||||
? props.renderRow(row, {
|
||||
getCellClassName: (columnId, className) =>
|
||||
cn(getColumnClassName(columnId, 'cell'), className),
|
||||
})
|
||||
: renderDefaultRow(props, row, getColumnClassName)
|
||||
)
|
||||
}
|
||||
|
||||
function renderEmptyState<TData>(
|
||||
props: DataTableViewProps<TData>,
|
||||
colSpan: number
|
||||
) {
|
||||
if (props.emptyContent) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={colSpan} className={props.emptyCellClassName}>
|
||||
{props.emptyContent}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TableEmpty
|
||||
colSpan={colSpan}
|
||||
title={props.emptyTitle}
|
||||
description={props.emptyDescription}
|
||||
icon={props.emptyIcon}
|
||||
>
|
||||
{props.emptyAction}
|
||||
</TableEmpty>
|
||||
)
|
||||
}
|
||||
|
||||
function renderDefaultRow<TData>(
|
||||
props: DataTableViewProps<TData>,
|
||||
row: Row<TData>,
|
||||
getColumnClassName: DataTableColumnClassName
|
||||
) {
|
||||
return (
|
||||
<DataTableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
className={cn(props.tableBodyRowClassName, props.getRowClassName?.(row))}
|
||||
getColumnClassName={getColumnClassName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+44
-40
@@ -39,48 +39,55 @@ type DataTablePaginationProps<TData> = {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [10, 20, 30, 40, 50, 100] as const
|
||||
const PAGE_SIZE_SELECT_ITEMS = PAGE_SIZE_OPTIONS.map((pageSize) => ({
|
||||
value: `${pageSize}`,
|
||||
label: pageSize,
|
||||
}))
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
const currentPage = table.getState().pagination.pageIndex + 1
|
||||
const pagination = table.getState().pagination
|
||||
const currentPage = pagination.pageIndex + 1
|
||||
const pageSize = pagination.pageSize
|
||||
const totalPages = table.getPageCount()
|
||||
const totalRows = table.getRowCount()
|
||||
const pageNumbers = getPageNumbers(currentPage, totalPages)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between overflow-clip',
|
||||
'@max-2xl/content:flex-col-reverse @max-2xl/content:gap-2 sm:@max-2xl/content:gap-4'
|
||||
'@container/pagination flex min-w-0 items-center justify-end overflow-clip'
|
||||
)}
|
||||
style={{ overflowClipMargin: 1 }}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between gap-2'>
|
||||
<div className='flex min-w-0 items-center text-xs font-medium whitespace-nowrap sm:min-w-[130px] sm:text-sm @2xl/content:hidden'>
|
||||
{t('Page {{current}} of {{total}}', {
|
||||
current: currentPage,
|
||||
total: totalPages,
|
||||
})}
|
||||
<div className='flex min-w-0 shrink-0 items-center gap-2 @xl/pagination:gap-3'>
|
||||
<div className='flex shrink-0 items-baseline gap-1.5 text-xs font-medium whitespace-nowrap sm:text-sm'>
|
||||
<span className='text-muted-foreground/80'>{t('Total:')}</span>
|
||||
<span className='text-foreground tabular-nums'>
|
||||
{totalRows.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 @max-2xl/content:flex-row-reverse'>
|
||||
|
||||
<div className='flex shrink-0 items-center gap-1.5 @lg/pagination:gap-2'>
|
||||
<p className='text-muted-foreground/80 hidden text-sm font-medium whitespace-nowrap @2xl/pagination:block'>
|
||||
{t('Rows per page')}
|
||||
</p>
|
||||
<Select
|
||||
items={[
|
||||
...[10, 20, 30, 40, 50, 100].map((pageSize) => ({
|
||||
value: `${pageSize}`,
|
||||
label: pageSize,
|
||||
})),
|
||||
]}
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
items={PAGE_SIZE_SELECT_ITEMS}
|
||||
value={`${pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='h-8 w-[64px] sm:w-[70px]'>
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
<SelectTrigger className='text-foreground h-8 w-[64px] font-medium tabular-nums sm:w-[70px]'>
|
||||
<SelectValue placeholder={pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side='top' alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{[10, 20, 30, 40, 50, 100].map((pageSize) => (
|
||||
{PAGE_SIZE_OPTIONS.map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
@@ -88,23 +95,12 @@ export function DataTablePagination<TData>({
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className='hidden text-sm font-medium sm:block'>
|
||||
{t('Rows per page')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center sm:space-x-6 lg:space-x-8'>
|
||||
<div className='flex min-w-[130px] items-center text-sm font-medium whitespace-nowrap @max-3xl/content:hidden'>
|
||||
{t('Page {{current}} of {{total}}', {
|
||||
current: currentPage,
|
||||
total: totalPages,
|
||||
})}
|
||||
</div>
|
||||
<div className='flex items-center space-x-1.5 sm:space-x-2'>
|
||||
<div className='flex min-w-0 shrink-0 items-center gap-1 @lg/pagination:gap-1.5 @xl/pagination:gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='size-8 p-0 @max-md/content:hidden'
|
||||
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0 @max-lg/pagination:hidden'
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
@@ -113,7 +109,7 @@ export function DataTablePagination<TData>({
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='size-8 p-0'
|
||||
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0'
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
@@ -121,18 +117,26 @@ export function DataTablePagination<TData>({
|
||||
<ChevronLeftIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
|
||||
{/* Page number buttons */}
|
||||
{pageNumbers.map((pageNumber, index) => (
|
||||
<div key={`${pageNumber}-${index}`} className='flex items-center'>
|
||||
{pageNumber === '...' ? (
|
||||
<span className='text-muted-foreground px-1 text-sm'>...</span>
|
||||
<span className='text-muted-foreground/60 px-0.5 text-sm @lg/pagination:px-1'>
|
||||
...
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant={currentPage === pageNumber ? 'default' : 'outline'}
|
||||
className='h-8 min-w-8 px-2'
|
||||
className={cn(
|
||||
'h-8 min-w-8 px-2 tabular-nums',
|
||||
currentPage === pageNumber
|
||||
? 'font-semibold'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
onClick={() => table.setPageIndex((pageNumber as number) - 1)}
|
||||
>
|
||||
<span className='sr-only'>Go to page {pageNumber}</span>
|
||||
<span className='sr-only'>
|
||||
{t('Go to page {{page}}', { page: pageNumber })}
|
||||
</span>
|
||||
{pageNumber}
|
||||
</Button>
|
||||
)}
|
||||
@@ -141,7 +145,7 @@ export function DataTablePagination<TData>({
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
className='size-8 p-0'
|
||||
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0'
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
@@ -150,7 +154,7 @@ export function DataTablePagination<TData>({
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='size-8 p-0 @max-md/content:hidden'
|
||||
className='text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50 size-8 p-0 @max-lg/pagination:hidden'
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
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 type * as React from 'react'
|
||||
import type { Table as TanstackTable } from '@tanstack/react-table'
|
||||
|
||||
export function getTableSizeStyle<TData>(
|
||||
table: TanstackTable<TData>
|
||||
): React.CSSProperties {
|
||||
const width = table
|
||||
.getVisibleLeafColumns()
|
||||
.reduce((total, column) => total + column.getSize(), 0)
|
||||
|
||||
return { minWidth: width, tableLayout: 'fixed', width: '100%' }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
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 type * as React from 'react'
|
||||
import type { Row, Table as TanstackTable } from '@tanstack/react-table'
|
||||
|
||||
export type DataTableColumnClassName = (
|
||||
columnId: string,
|
||||
kind: 'header' | 'cell'
|
||||
) => string | undefined
|
||||
|
||||
export type DataTablePinnedColumn = {
|
||||
columnId: string
|
||||
side: 'left' | 'right'
|
||||
className?: string
|
||||
headerClassName?: string
|
||||
cellClassName?: string
|
||||
}
|
||||
|
||||
export type DataTableRenderRowHelpers = {
|
||||
getCellClassName: (columnId: string, className?: string) => string | undefined
|
||||
}
|
||||
|
||||
export type DataTableViewProps<TData> = {
|
||||
table: TanstackTable<TData>
|
||||
isLoading?: boolean
|
||||
rows?: Row<TData>[]
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
emptyIcon?: React.ReactNode
|
||||
emptyAction?: React.ReactNode
|
||||
emptyContent?: React.ReactNode
|
||||
emptyCellClassName?: string
|
||||
skeletonKeyPrefix?: string
|
||||
skeletonRowHeight?: string
|
||||
renderRow?: (
|
||||
row: Row<TData>,
|
||||
helpers: DataTableRenderRowHelpers
|
||||
) => React.ReactNode
|
||||
getRowClassName?: (row: Row<TData>) => string | undefined
|
||||
getColumnClassName?: DataTableColumnClassName
|
||||
pinnedColumns?: DataTablePinnedColumn[]
|
||||
applyHeaderSize?: boolean
|
||||
tableClassName?: string
|
||||
tableHeaderClassName?: string
|
||||
tableHeaderRowClassName?: string
|
||||
tableBodyClassName?: string
|
||||
tableBodyRowClassName?: string
|
||||
splitHeader?: boolean
|
||||
splitHeaderScrollClassName?: string
|
||||
bodyContainerClassName?: string
|
||||
containerClassName?: string
|
||||
containerProps?: Omit<React.ComponentProps<'div'>, 'className' | 'children'>
|
||||
tableContainerClassName?: string
|
||||
colgroup?: React.ReactNode
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
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 {
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
type ExpandedState,
|
||||
type OnChangeFn,
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
type TableOptions,
|
||||
type Updater,
|
||||
type VisibilityState,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
|
||||
type DataTableFeatureOptions<TData> = Pick<
|
||||
TableOptions<TData>,
|
||||
| 'enableRowSelection'
|
||||
| 'getRowId'
|
||||
| 'getSubRows'
|
||||
| 'globalFilterFn'
|
||||
| 'autoResetPageIndex'
|
||||
| 'manualFiltering'
|
||||
| 'manualPagination'
|
||||
| 'manualSorting'
|
||||
>
|
||||
|
||||
type DataTableStateOptions = {
|
||||
initialSorting?: SortingState
|
||||
sorting?: SortingState
|
||||
onSortingChange?: OnChangeFn<SortingState>
|
||||
initialColumnVisibility?: VisibilityState
|
||||
columnVisibility?: VisibilityState
|
||||
onColumnVisibilityChange?: OnChangeFn<VisibilityState>
|
||||
initialRowSelection?: RowSelectionState
|
||||
rowSelection?: RowSelectionState
|
||||
onRowSelectionChange?: OnChangeFn<RowSelectionState>
|
||||
initialExpanded?: ExpandedState
|
||||
expanded?: ExpandedState
|
||||
onExpandedChange?: OnChangeFn<ExpandedState>
|
||||
columnFilters?: ColumnFiltersState
|
||||
onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>
|
||||
globalFilter?: string
|
||||
onGlobalFilterChange?: OnChangeFn<string>
|
||||
initialPagination?: PaginationState
|
||||
pagination?: PaginationState
|
||||
onPaginationChange?: OnChangeFn<PaginationState>
|
||||
}
|
||||
|
||||
type DataTableRowModelOptions = {
|
||||
withFilteredRowModel?: boolean
|
||||
withPaginationRowModel?: boolean
|
||||
withSortedRowModel?: boolean
|
||||
withFacetedRowModel?: boolean
|
||||
withExpandedRowModel?: boolean
|
||||
}
|
||||
|
||||
type UseDataTableOptions<TData> = DataTableFeatureOptions<TData> &
|
||||
DataTableStateOptions &
|
||||
DataTableRowModelOptions & {
|
||||
data: TData[]
|
||||
columns: ColumnDef<TData, unknown>[]
|
||||
totalCount?: number
|
||||
pageCount?: number
|
||||
ensurePageInRange?: (pageCount: number) => void
|
||||
}
|
||||
|
||||
function resolveUpdater<TValue>(
|
||||
updater: Updater<TValue>,
|
||||
previous: TValue
|
||||
): TValue {
|
||||
return typeof updater === 'function'
|
||||
? (updater as (old: TValue) => TValue)(previous)
|
||||
: updater
|
||||
}
|
||||
|
||||
function useControllableTableState<TValue>(
|
||||
controlledValue: TValue | undefined,
|
||||
defaultValue: TValue,
|
||||
onChange: OnChangeFn<TValue> | undefined
|
||||
): [TValue, OnChangeFn<TValue>] {
|
||||
const [uncontrolledValue, setUncontrolledValue] =
|
||||
React.useState<TValue>(defaultValue)
|
||||
|
||||
const value = controlledValue ?? uncontrolledValue
|
||||
|
||||
const setValue = React.useCallback<OnChangeFn<TValue>>(
|
||||
(updater) => {
|
||||
if (controlledValue === undefined) {
|
||||
setUncontrolledValue((previous) => resolveUpdater(updater, previous))
|
||||
}
|
||||
onChange?.(updater)
|
||||
},
|
||||
[controlledValue, onChange]
|
||||
)
|
||||
|
||||
return [value, setValue]
|
||||
}
|
||||
|
||||
export function useDataTable<TData>(options: UseDataTableOptions<TData>) {
|
||||
const {
|
||||
data,
|
||||
columns,
|
||||
totalCount,
|
||||
pageCount: explicitPageCount,
|
||||
ensurePageInRange,
|
||||
manualFiltering,
|
||||
manualPagination,
|
||||
manualSorting,
|
||||
initialSorting = [],
|
||||
initialColumnVisibility = {},
|
||||
initialRowSelection = {},
|
||||
initialExpanded = {},
|
||||
initialPagination = { pageIndex: 0, pageSize: 20 },
|
||||
withFilteredRowModel = !manualFiltering,
|
||||
withPaginationRowModel = !manualPagination,
|
||||
withSortedRowModel = !manualSorting,
|
||||
withFacetedRowModel = !manualFiltering,
|
||||
withExpandedRowModel = false,
|
||||
} = options
|
||||
|
||||
const [sorting, onSortingChange] = useControllableTableState(
|
||||
options.sorting,
|
||||
initialSorting,
|
||||
options.onSortingChange
|
||||
)
|
||||
const [columnVisibility, onColumnVisibilityChange] =
|
||||
useControllableTableState(
|
||||
options.columnVisibility,
|
||||
initialColumnVisibility,
|
||||
options.onColumnVisibilityChange
|
||||
)
|
||||
const [rowSelection, onRowSelectionChange] = useControllableTableState(
|
||||
options.rowSelection,
|
||||
initialRowSelection,
|
||||
options.onRowSelectionChange
|
||||
)
|
||||
const [expanded, onExpandedChange] = useControllableTableState(
|
||||
options.expanded,
|
||||
initialExpanded,
|
||||
options.onExpandedChange
|
||||
)
|
||||
const [pagination, onPaginationChange] = useControllableTableState(
|
||||
options.pagination,
|
||||
initialPagination,
|
||||
options.onPaginationChange
|
||||
)
|
||||
|
||||
const resolvedPageCount =
|
||||
explicitPageCount ??
|
||||
(totalCount !== undefined
|
||||
? Math.ceil(totalCount / pagination.pageSize)
|
||||
: undefined)
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
rowCount: totalCount,
|
||||
pageCount: resolvedPageCount,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
expanded,
|
||||
columnFilters: options.columnFilters,
|
||||
globalFilter: options.globalFilter,
|
||||
pagination,
|
||||
},
|
||||
enableRowSelection: options.enableRowSelection,
|
||||
getRowId: options.getRowId,
|
||||
getSubRows: options.getSubRows,
|
||||
globalFilterFn: options.globalFilterFn,
|
||||
autoResetPageIndex: options.autoResetPageIndex,
|
||||
manualFiltering,
|
||||
manualPagination,
|
||||
manualSorting,
|
||||
onSortingChange,
|
||||
onColumnVisibilityChange,
|
||||
onRowSelectionChange,
|
||||
onExpandedChange,
|
||||
onColumnFiltersChange: options.onColumnFiltersChange,
|
||||
onGlobalFilterChange: options.onGlobalFilterChange,
|
||||
onPaginationChange,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: withFilteredRowModel
|
||||
? getFilteredRowModel()
|
||||
: undefined,
|
||||
getPaginationRowModel: withPaginationRowModel
|
||||
? getPaginationRowModel()
|
||||
: undefined,
|
||||
getSortedRowModel: withSortedRowModel ? getSortedRowModel() : undefined,
|
||||
getFacetedRowModel: withFacetedRowModel ? getFacetedRowModel() : undefined,
|
||||
getFacetedUniqueValues: withFacetedRowModel
|
||||
? getFacetedUniqueValues()
|
||||
: undefined,
|
||||
getExpandedRowModel: withExpandedRowModel
|
||||
? getExpandedRowModel()
|
||||
: undefined,
|
||||
})
|
||||
|
||||
const actualPageCount = table.getPageCount()
|
||||
React.useEffect(() => {
|
||||
ensurePageInRange?.(actualPageCount)
|
||||
}, [actualPageCount, ensurePageInRange])
|
||||
|
||||
return {
|
||||
table,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
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 type { ColumnFiltersState, OnChangeFn } from '@tanstack/react-table'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
|
||||
type UseDebouncedColumnFilterOptions = {
|
||||
columnFilters: ColumnFiltersState
|
||||
columnId: string
|
||||
onColumnFiltersChange: OnChangeFn<ColumnFiltersState>
|
||||
delay?: number
|
||||
}
|
||||
|
||||
export function useDebouncedColumnFilter({
|
||||
columnFilters,
|
||||
columnId,
|
||||
onColumnFiltersChange,
|
||||
delay = 500,
|
||||
}: UseDebouncedColumnFilterOptions) {
|
||||
const value =
|
||||
(columnFilters.find((filter) => filter.id === columnId)?.value as
|
||||
| string
|
||||
| undefined) ?? ''
|
||||
const [inputValue, setInputValue] = React.useState(value)
|
||||
const [pendingValue, setPendingValue] = React.useState(value)
|
||||
const isComposingRef = React.useRef(false)
|
||||
const debouncedValue = useDebounce(pendingValue, delay)
|
||||
|
||||
React.useEffect(() => {
|
||||
// Keep the input aligned when URL state changes outside the local field.
|
||||
if (!isComposingRef.current) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setInputValue(value)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setPendingValue(value)
|
||||
}, [value])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (debouncedValue === value) return
|
||||
|
||||
onColumnFiltersChange((previous) => {
|
||||
const filters = previous.filter((filter) => filter.id !== columnId)
|
||||
return debouncedValue
|
||||
? [...filters, { id: columnId, value: debouncedValue }]
|
||||
: filters
|
||||
})
|
||||
}, [columnId, debouncedValue, onColumnFiltersChange, value])
|
||||
|
||||
const updateInputValue = React.useCallback((nextValue: string) => {
|
||||
setInputValue(nextValue)
|
||||
|
||||
if (!isComposingRef.current) {
|
||||
setPendingValue(nextValue)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateInputValue(event.target.value)
|
||||
},
|
||||
[updateInputValue]
|
||||
)
|
||||
|
||||
const handleCompositionStart = React.useCallback(() => {
|
||||
isComposingRef.current = true
|
||||
}, [])
|
||||
|
||||
const handleCompositionEnd = React.useCallback(
|
||||
(event: React.CompositionEvent<HTMLInputElement>) => {
|
||||
isComposingRef.current = false
|
||||
const nextValue = event.currentTarget.value
|
||||
setInputValue(nextValue)
|
||||
setPendingValue(nextValue)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const resetInput = React.useCallback(() => {
|
||||
isComposingRef.current = false
|
||||
setInputValue('')
|
||||
setPendingValue('')
|
||||
}, [])
|
||||
|
||||
return {
|
||||
value,
|
||||
inputValue,
|
||||
setInputValue: updateInputValue,
|
||||
onChange: handleChange,
|
||||
onCompositionStart: handleCompositionStart,
|
||||
onCompositionEnd: handleCompositionEnd,
|
||||
resetInput,
|
||||
}
|
||||
}
|
||||
+24
-10
@@ -16,16 +16,30 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export { DataTablePagination } from './pagination'
|
||||
export { DataTableColumnHeader } from './column-header'
|
||||
export { DataTableFacetedFilter } from './faceted-filter'
|
||||
export { DataTableViewOptions } from './view-options'
|
||||
export { DataTableToolbar } from './toolbar'
|
||||
export { DataTableBulkActions } from './bulk-actions'
|
||||
export { TableSkeleton } from './table-skeleton'
|
||||
export { TableEmpty } from './table-empty'
|
||||
export { MobileCardList } from './mobile-card-list'
|
||||
export { DataTablePage, type DataTablePageProps } from './data-table-page'
|
||||
export { DataTablePagination } from './core/pagination'
|
||||
export { DataTableColumnHeader } from './core/column-header'
|
||||
export { DataTableViewOptions } from './toolbar/view-options'
|
||||
export { DataTableToolbar } from './toolbar/toolbar'
|
||||
export { DataTableBulkActions } from './toolbar/bulk-actions'
|
||||
export {
|
||||
StaticDataTable,
|
||||
type StaticDataTableColumn,
|
||||
} from './static/static-data-table'
|
||||
export { staticDataTableClassNames } from './static/static-data-table-classnames'
|
||||
export {
|
||||
DataTableRow,
|
||||
DataTableView,
|
||||
type DataTableColumnClassName,
|
||||
type DataTablePinnedColumn,
|
||||
type DataTableRenderRowHelpers,
|
||||
} from './core/data-table-view'
|
||||
export { MobileCardList } from './layout/mobile-card-list'
|
||||
export {
|
||||
DataTablePage,
|
||||
type DataTablePageProps,
|
||||
} from './layout/data-table-page'
|
||||
export { useDataTable } from './hooks/use-data-table'
|
||||
export { useDebouncedColumnFilter } from './hooks/use-debounced-column-filter'
|
||||
|
||||
export const DISABLED_ROW_DESKTOP =
|
||||
'bg-muted/85 hover:bg-muted [&>td:first-child]:border-l-muted-foreground/35 [&>td:first-child]:border-l-4 [&>td:first-child]:pl-1'
|
||||
|
||||
+85
-112
@@ -18,27 +18,22 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import * as React from 'react'
|
||||
import {
|
||||
flexRender,
|
||||
type ColumnDef,
|
||||
type Row,
|
||||
type Table as TanstackTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { PageFooterPortal } from '@/components/layout'
|
||||
import {
|
||||
DataTableView,
|
||||
type DataTableColumnClassName,
|
||||
type DataTablePinnedColumn,
|
||||
type DataTableRenderRowHelpers,
|
||||
} from '../core/data-table-view'
|
||||
import { DataTablePagination } from '../core/pagination'
|
||||
import { DataTableToolbar } from '../toolbar/toolbar'
|
||||
import { MobileCardList } from './mobile-card-list'
|
||||
import { DataTablePagination } from './pagination'
|
||||
import { TableEmpty } from './table-empty'
|
||||
import { TableSkeleton } from './table-skeleton'
|
||||
import { DataTableToolbar } from './toolbar'
|
||||
|
||||
/**
|
||||
* Pass-through configuration for the default {@link DataTableToolbar}.
|
||||
@@ -145,7 +140,22 @@ export type DataTablePageProps<TData> = {
|
||||
* Custom desktop row renderer — replaces the default `<TableRow>`/`<TableCell>` mapping.
|
||||
* Use for expanded rows, aggregate rows, click-on-row navigation, etc.
|
||||
*/
|
||||
renderRow?: (row: Row<TData>) => React.ReactNode
|
||||
renderRow?: (
|
||||
row: Row<TData>,
|
||||
helpers: DataTableRenderRowHelpers
|
||||
) => React.ReactNode
|
||||
|
||||
/**
|
||||
* Desktop column className resolver. Use for semantic alignment/spacing only;
|
||||
* fixed-column behavior should be configured with `pinnedColumns`.
|
||||
*/
|
||||
getColumnClassName?: DataTableColumnClassName
|
||||
|
||||
/**
|
||||
* Fixed desktop columns. The shared table component owns sticky position,
|
||||
* layering, shadows, and row-state backgrounds.
|
||||
*/
|
||||
pinnedColumns?: DataTablePinnedColumn[]
|
||||
|
||||
/**
|
||||
* Apply explicit column widths from `header.getSize()` to `<TableHead>`.
|
||||
@@ -182,6 +192,12 @@ export type DataTablePageProps<TData> = {
|
||||
*/
|
||||
className?: string
|
||||
|
||||
/**
|
||||
* Make the desktop table consume the available page height and scroll inside
|
||||
* the table body while keeping the header fixed. Defaults to `true`.
|
||||
*/
|
||||
fixedHeight?: boolean
|
||||
|
||||
/**
|
||||
* Desktop table container className (the bordered scroll wrapper).
|
||||
*/
|
||||
@@ -189,7 +205,8 @@ export type DataTablePageProps<TData> = {
|
||||
|
||||
/**
|
||||
* Desktop `<TableHeader>` className override.
|
||||
* Useful for sticky headers (`'sticky top-0 z-10 bg-muted/30'`) on long lists.
|
||||
* Use for header color/spacing overrides. Fixed-height pages keep the header
|
||||
* outside the scrollable body automatically.
|
||||
*/
|
||||
tableHeaderClassName?: string
|
||||
}
|
||||
@@ -222,10 +239,18 @@ export function DataTablePage<TData>(props: DataTablePageProps<TData>) {
|
||||
const toolbarNode = renderToolbar(props)
|
||||
const mobileNode = renderMobile(props, showMobile)
|
||||
const desktopNode = renderDesktop(props, showMobile)
|
||||
const paginationNode = renderPagination(props)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn('space-y-2.5 sm:space-y-3', props.className)}>
|
||||
<div
|
||||
className={cn(
|
||||
props.fixedHeight !== false
|
||||
? 'flex h-full min-h-0 flex-col gap-2.5 sm:gap-3'
|
||||
: 'space-y-2.5 sm:space-y-3',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{toolbarNode}
|
||||
{mobileNode}
|
||||
{desktopNode}
|
||||
@@ -236,16 +261,7 @@ export function DataTablePage<TData>(props: DataTablePageProps<TData>) {
|
||||
handle its own visibility, we just gate it to non-mobile. */}
|
||||
{!showMobile && props.bulkActions}
|
||||
|
||||
{props.showPagination !== false &&
|
||||
(props.paginationInFooter !== false ? (
|
||||
<PageFooterPortal>
|
||||
<DataTablePagination table={props.table} />
|
||||
</PageFooterPortal>
|
||||
) : (
|
||||
<div className='pt-2'>
|
||||
<DataTablePagination table={props.table} />
|
||||
</div>
|
||||
))}
|
||||
{paginationNode}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -265,12 +281,25 @@ function renderToolbar<TData>(
|
||||
return null
|
||||
}
|
||||
|
||||
function renderPagination<TData>(
|
||||
props: DataTablePageProps<TData>
|
||||
): React.ReactNode {
|
||||
if (props.showPagination === false) return null
|
||||
|
||||
const pagination = <DataTablePagination table={props.table} />
|
||||
|
||||
return props.paginationInFooter !== false ? (
|
||||
<PageFooterPortal>{pagination}</PageFooterPortal>
|
||||
) : (
|
||||
<div className='pt-2'>{pagination}</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderMobile<TData>(
|
||||
props: DataTablePageProps<TData>,
|
||||
showMobile: boolean
|
||||
): React.ReactNode {
|
||||
if (!showMobile) return null
|
||||
if (props.mobile !== undefined) return props.mobile
|
||||
|
||||
const ownGetRowClassName = props.getRowClassName
|
||||
const mobileGetRowClassName =
|
||||
@@ -278,8 +307,7 @@ function renderMobile<TData>(
|
||||
(ownGetRowClassName
|
||||
? (row: Row<TData>) => ownGetRowClassName(row, { isMobile: true })
|
||||
: undefined)
|
||||
|
||||
return (
|
||||
const mobileContent = props.mobile ?? (
|
||||
<MobileCardList
|
||||
table={props.table}
|
||||
isLoading={props.isLoading}
|
||||
@@ -289,6 +317,8 @@ function renderMobile<TData>(
|
||||
getRowClassName={mobileGetRowClassName}
|
||||
/>
|
||||
)
|
||||
|
||||
return <div className='min-h-0 flex-1 overflow-y-auto'>{mobileContent}</div>
|
||||
}
|
||||
|
||||
function renderDesktop<TData>(
|
||||
@@ -297,94 +327,37 @@ function renderDesktop<TData>(
|
||||
): React.ReactNode {
|
||||
if (showMobile) return null
|
||||
|
||||
const rows = props.table.getRowModel().rows
|
||||
const isFetchingOnly = props.isFetching && !props.isLoading
|
||||
const fixedHeight = props.fixedHeight !== false
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-lg border transition-opacity duration-150',
|
||||
<DataTableView
|
||||
table={props.table}
|
||||
isLoading={props.isLoading}
|
||||
emptyTitle={props.emptyTitle}
|
||||
emptyDescription={props.emptyDescription}
|
||||
emptyIcon={props.emptyIcon}
|
||||
emptyAction={props.emptyAction}
|
||||
skeletonKeyPrefix={props.skeletonKeyPrefix}
|
||||
renderRow={props.renderRow}
|
||||
applyHeaderSize={props.applyHeaderSize}
|
||||
splitHeader={fixedHeight}
|
||||
tableContainerClassName={fixedHeight ? 'h-full min-h-0' : undefined}
|
||||
tableHeaderClassName={cn(
|
||||
fixedHeight && 'bg-muted/30',
|
||||
props.tableHeaderClassName
|
||||
)}
|
||||
getColumnClassName={props.getColumnClassName}
|
||||
pinnedColumns={props.pinnedColumns}
|
||||
containerClassName={cn(
|
||||
fixedHeight && 'min-h-0 flex-1',
|
||||
'transition-opacity duration-150',
|
||||
isFetchingOnly && 'pointer-events-none opacity-60',
|
||||
props.tableClassName
|
||||
)}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader className={props.tableHeaderClassName}>
|
||||
{props.table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
style={
|
||||
props.applyHeaderSize
|
||||
? { width: header.getSize() }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{props.isLoading ? (
|
||||
<TableSkeleton
|
||||
table={props.table}
|
||||
keyPrefix={props.skeletonKeyPrefix}
|
||||
/>
|
||||
) : rows.length === 0 ? (
|
||||
<TableEmpty
|
||||
colSpan={props.columns.length}
|
||||
title={props.emptyTitle}
|
||||
description={props.emptyDescription}
|
||||
icon={props.emptyIcon}
|
||||
>
|
||||
{props.emptyAction}
|
||||
</TableEmpty>
|
||||
) : (
|
||||
rows.map((row) => {
|
||||
if (props.renderRow) {
|
||||
return props.renderRow(row)
|
||||
}
|
||||
return (
|
||||
<DefaultRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
className={props.getRowClassName?.(row, { isMobile: false })}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DefaultRow<TData>({
|
||||
row,
|
||||
className,
|
||||
}: {
|
||||
row: Row<TData>
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
className={className}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
getRowClassName={(row) =>
|
||||
props.getRowClassName?.(row, { isMobile: false })
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
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
|
||||
*/
|
||||
export const staticDataTableClassNames = {
|
||||
container: 'overflow-hidden rounded-md border',
|
||||
sectionContainer: 'border-border/60 rounded-lg',
|
||||
embeddedContainer: 'rounded-none border-0',
|
||||
compactTable: 'text-sm',
|
||||
compactHeaderRow: 'hover:bg-transparent',
|
||||
mutedHeaderRow: 'bg-muted/30 hover:bg-muted/30',
|
||||
compactHeaderCell:
|
||||
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase',
|
||||
compactHeaderCellRight:
|
||||
'text-muted-foreground py-2 text-right text-[10px] font-medium tracking-wider uppercase',
|
||||
compactCell: 'py-2.5',
|
||||
compactTopCell: 'py-2.5 align-top',
|
||||
compactTopNumericCell: 'py-2.5 text-right align-top font-mono',
|
||||
compactMutedCell: 'text-muted-foreground py-2.5',
|
||||
compactMutedCodeCell: 'text-muted-foreground py-2.5 font-mono',
|
||||
compactNumericCell: 'py-2.5 text-right font-mono',
|
||||
compactMutedNumericCell: 'text-muted-foreground py-2.5 text-right font-mono',
|
||||
topCell: 'py-2 align-top',
|
||||
topMutedCell: 'text-muted-foreground py-2 align-top',
|
||||
codeCell: 'font-mono text-sm',
|
||||
mutedCell: 'text-muted-foreground text-sm',
|
||||
mutedCodeCell: 'text-muted-foreground font-mono text-sm',
|
||||
topNumericCell: 'py-2 text-right font-mono',
|
||||
mediumCell: 'font-medium',
|
||||
actionHeaderCell: 'text-right',
|
||||
actionCell: 'text-right',
|
||||
} as const
|
||||
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
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 {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { staticDataTableClassNames } from './static-data-table-classnames'
|
||||
|
||||
type StaticDataTableBaseProps = {
|
||||
className?: string
|
||||
tableClassName?: string
|
||||
containerProps?: Omit<React.ComponentProps<'div'>, 'className' | 'children'>
|
||||
tableProps?: Omit<
|
||||
React.ComponentProps<typeof Table>,
|
||||
'className' | 'children'
|
||||
>
|
||||
}
|
||||
|
||||
type StaticDataTableDataProps<TData = unknown> = StaticDataTableBaseProps & {
|
||||
columns: StaticDataTableColumn<TData>[]
|
||||
data: TData[]
|
||||
getRowKey?: (row: TData, index: number) => React.Key
|
||||
getRowClassName?: (row: TData, index: number) => string | undefined
|
||||
renderRow?: (row: TData, index: number) => React.ReactNode
|
||||
empty?: boolean
|
||||
emptyContent?: React.ReactNode
|
||||
emptyClassName?: string
|
||||
headerRowClassName?: string
|
||||
}
|
||||
|
||||
type StaticDataTableChildrenProps = StaticDataTableBaseProps & {
|
||||
children: React.ReactNode
|
||||
columns?: never
|
||||
data?: never
|
||||
}
|
||||
|
||||
type StaticDataTableProps<TData = unknown> =
|
||||
| StaticDataTableDataProps<TData>
|
||||
| StaticDataTableChildrenProps
|
||||
|
||||
export type StaticDataTableColumn<TData = unknown> = {
|
||||
id: string
|
||||
header: React.ReactNode
|
||||
className?: string
|
||||
cellClassName?: string | ((row: TData, index: number) => string | undefined)
|
||||
cell?: (row: TData, index: number) => React.ReactNode
|
||||
}
|
||||
|
||||
export function StaticDataTable<TData = unknown>(
|
||||
props: StaticDataTableProps<TData>
|
||||
) {
|
||||
const { className, tableClassName, containerProps, tableProps } = props
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(staticDataTableClassNames.container, className)}
|
||||
{...containerProps}
|
||||
>
|
||||
<Table className={tableClassName} {...tableProps}>
|
||||
{props.columns !== undefined ? (
|
||||
<StaticDataTableWithColumns {...props} />
|
||||
) : (
|
||||
props.children
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StaticDataTableWithColumns<TData>({
|
||||
columns,
|
||||
data,
|
||||
getRowKey,
|
||||
getRowClassName,
|
||||
renderRow,
|
||||
empty,
|
||||
emptyContent,
|
||||
emptyClassName,
|
||||
headerRowClassName,
|
||||
}: StaticDataTableDataProps<TData>) {
|
||||
const isEmpty = empty ?? (data !== undefined && data.length === 0)
|
||||
const bodyRows = data.map((row, index) => (
|
||||
<StaticDataTableRow
|
||||
key={getRowKey?.(row, index) ?? index}
|
||||
row={row}
|
||||
index={index}
|
||||
columns={columns}
|
||||
getRowClassName={getRowClassName}
|
||||
renderRow={renderRow}
|
||||
/>
|
||||
))
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableHeader>
|
||||
<TableRow className={headerRowClassName}>
|
||||
{columns.map((column) => (
|
||||
<TableHead key={column.id} className={column.className}>
|
||||
{column.header}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isEmpty ? (
|
||||
<StaticDataTableEmptyRow
|
||||
colSpan={columns.length}
|
||||
className={emptyClassName}
|
||||
>
|
||||
{emptyContent}
|
||||
</StaticDataTableEmptyRow>
|
||||
) : (
|
||||
bodyRows
|
||||
)}
|
||||
</TableBody>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type StaticDataTableRowProps<TData> = Required<
|
||||
Pick<StaticDataTableDataProps<TData>, 'columns'>
|
||||
> &
|
||||
Pick<StaticDataTableDataProps<TData>, 'getRowClassName' | 'renderRow'> & {
|
||||
row: TData
|
||||
index: number
|
||||
}
|
||||
|
||||
function StaticDataTableRow<TData>({
|
||||
row,
|
||||
index,
|
||||
columns,
|
||||
getRowClassName,
|
||||
renderRow,
|
||||
}: StaticDataTableRowProps<TData>) {
|
||||
if (renderRow) {
|
||||
return <>{renderRow(row, index)}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow className={getRowClassName?.(row, index)}>
|
||||
{columns.map((column) => (
|
||||
<TableCell
|
||||
key={column.id}
|
||||
className={getStaticCellClassName(column, row, index)}
|
||||
>
|
||||
{column.cell?.(row, index)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
function getStaticCellClassName<TData>(
|
||||
column: StaticDataTableColumn<TData>,
|
||||
row: TData,
|
||||
index: number
|
||||
) {
|
||||
return typeof column.cellClassName === 'function'
|
||||
? column.cellClassName(row, index)
|
||||
: column.cellClassName
|
||||
}
|
||||
|
||||
type StaticDataTableEmptyRowProps = {
|
||||
colSpan: number
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function StaticDataTableEmptyRow({
|
||||
colSpan,
|
||||
children,
|
||||
className,
|
||||
}: StaticDataTableEmptyRowProps) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={colSpan}
|
||||
className={cn('h-24 text-center', className)}
|
||||
>
|
||||
{children}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
+105
-11
@@ -21,6 +21,7 @@ import { useState, type ReactNode } from 'react'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { ChevronDown, Loader2, X as Cross2Icon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDebounce } from '@/hooks'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -46,6 +47,10 @@ export type DataTableToolbarProps<TData> = {
|
||||
* Placeholder for the default search input. Defaults to `t('Filter...')`.
|
||||
*/
|
||||
searchPlaceholder?: string
|
||||
/**
|
||||
* Delay committing the default search input. Defaults to immediate updates.
|
||||
*/
|
||||
searchDebounceMs?: number
|
||||
/**
|
||||
* Column id to filter on. When provided, the search input filters
|
||||
* a specific column. When omitted, the search input updates the
|
||||
@@ -136,6 +141,8 @@ export type DataTableToolbarProps<TData> = {
|
||||
export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const isSearchComposingRef = React.useRef(false)
|
||||
const lastCommittedSearchValueRef = React.useRef('')
|
||||
|
||||
const filters = props.filters ?? []
|
||||
const hasExpandable = props.expandable != null
|
||||
@@ -147,26 +154,109 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
|
||||
!!props.hasAdditionalFilters
|
||||
|
||||
const placeholder = props.searchPlaceholder ?? t('Filter...')
|
||||
const currentSearchValue = props.searchKey
|
||||
? ((props.table.getColumn(props.searchKey)?.getFilterValue() as string) ??
|
||||
'')
|
||||
: ((props.table.getState().globalFilter as string | undefined) ?? '')
|
||||
|
||||
const [searchValue, setSearchValue] = useState(currentSearchValue)
|
||||
const [pendingSearchValue, setPendingSearchValue] =
|
||||
useState(currentSearchValue)
|
||||
const searchDebounceMs = Math.max(0, props.searchDebounceMs ?? 0)
|
||||
const debouncedSearchValue = useDebounce(
|
||||
pendingSearchValue,
|
||||
searchDebounceMs
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
lastCommittedSearchValueRef.current = currentSearchValue
|
||||
if (!isSearchComposingRef.current) {
|
||||
setSearchValue(currentSearchValue)
|
||||
}
|
||||
setPendingSearchValue(currentSearchValue)
|
||||
}, [currentSearchValue])
|
||||
|
||||
const commitSearchValue = React.useCallback(
|
||||
(value: string) => {
|
||||
if (value === lastCommittedSearchValueRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
lastCommittedSearchValueRef.current = value
|
||||
|
||||
if (props.searchKey) {
|
||||
props.table.getColumn(props.searchKey)?.setFilterValue(value)
|
||||
return
|
||||
}
|
||||
|
||||
props.table.setGlobalFilter(value)
|
||||
},
|
||||
[props.searchKey, props.table]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
searchDebounceMs <= 0 ||
|
||||
isSearchComposingRef.current ||
|
||||
debouncedSearchValue !== pendingSearchValue
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
commitSearchValue(debouncedSearchValue)
|
||||
}, [
|
||||
commitSearchValue,
|
||||
debouncedSearchValue,
|
||||
pendingSearchValue,
|
||||
searchDebounceMs,
|
||||
])
|
||||
|
||||
const queueSearchValue = (value: string) => {
|
||||
setPendingSearchValue(value)
|
||||
|
||||
if (searchDebounceMs <= 0) {
|
||||
commitSearchValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value
|
||||
setSearchValue(value)
|
||||
|
||||
if (!isSearchComposingRef.current) {
|
||||
queueSearchValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearchCompositionStart = () => {
|
||||
isSearchComposingRef.current = true
|
||||
}
|
||||
|
||||
const handleSearchCompositionEnd = (
|
||||
event: React.CompositionEvent<HTMLInputElement>
|
||||
) => {
|
||||
isSearchComposingRef.current = false
|
||||
const value = event.currentTarget.value
|
||||
setSearchValue(value)
|
||||
queueSearchValue(value)
|
||||
}
|
||||
|
||||
const searchInput = props.searchKey ? (
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={
|
||||
(props.table.getColumn(props.searchKey)?.getFilterValue() as string) ??
|
||||
''
|
||||
}
|
||||
onChange={(event) =>
|
||||
props.table
|
||||
.getColumn(props.searchKey!)
|
||||
?.setFilterValue(event.target.value)
|
||||
}
|
||||
value={searchValue}
|
||||
onChange={handleSearchChange}
|
||||
onCompositionStart={handleSearchCompositionStart}
|
||||
onCompositionEnd={handleSearchCompositionEnd}
|
||||
className='w-full sm:w-[200px] lg:w-[240px]'
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={props.table.getState().globalFilter ?? ''}
|
||||
onChange={(event) => props.table.setGlobalFilter(event.target.value)}
|
||||
value={searchValue}
|
||||
onChange={handleSearchChange}
|
||||
onCompositionStart={handleSearchCompositionStart}
|
||||
onCompositionEnd={handleSearchCompositionEnd}
|
||||
className='w-full sm:w-[200px] lg:w-[240px]'
|
||||
/>
|
||||
)
|
||||
@@ -186,6 +276,10 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
|
||||
})
|
||||
|
||||
const handleReset = () => {
|
||||
isSearchComposingRef.current = false
|
||||
setSearchValue('')
|
||||
setPendingSearchValue('')
|
||||
lastCommittedSearchValueRef.current = ''
|
||||
props.table.resetColumnFilters()
|
||||
props.table.setGlobalFilter('')
|
||||
props.onReset?.()
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
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>
|
||||
)
|
||||
}
|
||||
+17
-26
@@ -25,15 +25,8 @@ 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'
|
||||
@@ -427,28 +420,26 @@ export function PublicHeader(props: PublicHeaderProps) {
|
||||
closeAuthPrompt()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
title={t('Sign in required')}
|
||||
description={t('Please sign in to view {{module}}.', {
|
||||
module: authPromptTarget?.title || '',
|
||||
})}
|
||||
contentClassName='sm:max-w-md'
|
||||
contentHeight='auto'
|
||||
footer={
|
||||
<>
|
||||
<Button variant='outline' onClick={closeAuthPrompt}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={navigateToSignIn}>{t('Sign in now')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -50,6 +50,7 @@ SectionPageLayoutBreadcrumb.displayName = 'SectionPageLayout.Breadcrumb'
|
||||
|
||||
export type SectionPageLayoutProps = {
|
||||
children: ReactNode
|
||||
fixedContent?: boolean
|
||||
}
|
||||
|
||||
export function SectionPageLayout(props: SectionPageLayoutProps) {
|
||||
@@ -95,7 +96,13 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='min-h-0 flex-1 overflow-auto px-3 pt-1 pb-3 sm:px-4 sm:pt-1.5 sm:pb-4'>
|
||||
<div
|
||||
className={
|
||||
props.fixedContent
|
||||
? 'min-h-0 flex-1 overflow-hidden px-3 pt-1 pb-3 sm:px-4 sm:pt-1.5 sm:pb-4'
|
||||
: 'min-h-0 flex-1 overflow-auto px-3 pt-1 pb-3 sm:px-4 sm:pt-1.5 sm:pb-4'
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
|
||||
|
||||
-1
@@ -46,7 +46,6 @@ export function LongText({
|
||||
|
||||
useEffect(() => {
|
||||
if (checkOverflow(ref.current)) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsOverflown(true)
|
||||
return
|
||||
}
|
||||
|
||||
+7
-3
@@ -42,14 +42,18 @@ interface MaskedValueDisplayProps {
|
||||
*/
|
||||
export function MaskedValueDisplay(props: MaskedValueDisplayProps) {
|
||||
return (
|
||||
<div className='flex items-center'>
|
||||
<div className='flex max-w-full min-w-0 items-center'>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant='ghost' size='sm' className='h-7 font-mono' />
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 max-w-full min-w-0 justify-start truncate px-0 font-mono hover:bg-transparent aria-expanded:bg-transparent'
|
||||
/>
|
||||
}
|
||||
>
|
||||
{props.maskedValue}
|
||||
<span className='truncate'>{props.maskedValue}</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className='w-auto max-w-[min(90vw,28rem)]'
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
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 { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { StatusBadge, type StatusBadgeProps } from './status-badge'
|
||||
|
||||
type ProviderBadgeProps = Omit<StatusBadgeProps, 'children' | 'label'> & {
|
||||
iconKey?: string | null
|
||||
iconSize?: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export function ProviderBadge({
|
||||
className,
|
||||
iconKey,
|
||||
iconSize = 14,
|
||||
label,
|
||||
...badgeProps
|
||||
}: ProviderBadgeProps) {
|
||||
const icon = iconKey ? getLobeIcon(iconKey, iconSize) : null
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1.5', className)}>
|
||||
{icon}
|
||||
<StatusBadge label={label} autoColor={label} size='sm' {...badgeProps} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+2
-1
@@ -103,7 +103,7 @@ export function StatusBadge({
|
||||
variant,
|
||||
size = 'sm',
|
||||
pulse = false,
|
||||
showDot = true,
|
||||
showDot = false,
|
||||
copyable = true,
|
||||
copyText,
|
||||
autoColor,
|
||||
@@ -130,6 +130,7 @@ export function StatusBadge({
|
||||
|
||||
return (
|
||||
<span
|
||||
data-slot='status-badge'
|
||||
className={cn(
|
||||
'inline-flex w-fit max-w-full shrink-0 items-center rounded-4xl font-medium tracking-normal whitespace-nowrap transition-colors',
|
||||
sizeMap[size ?? 'sm'],
|
||||
|
||||
+1
-1
@@ -180,7 +180,7 @@ function ComboboxContent({
|
||||
data-slot='combobox-content'
|
||||
data-chips={!!anchor}
|
||||
className={cn(
|
||||
'dark group/combobox-content bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 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 relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg shadow-md ring-1 duration-100 data-[chips=true]:min-w-(--anchor-width) *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none',
|
||||
'group/combobox-content bg-popover text-popover-foreground ring-foreground/10 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 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 relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg shadow-md ring-1 duration-100 data-[chips=true]:min-w-(--anchor-width) *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
+1
-1
@@ -103,7 +103,7 @@ function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
|
||||
<td
|
||||
data-slot='table-cell'
|
||||
className={cn(
|
||||
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0',
|
||||
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>*:has(>[data-slot=status-badge]:first-child):first-child]:-ml-1.5 [&>[data-slot=status-badge]:first-child]:-ml-1.5',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Vendored
+106
-117
@@ -20,16 +20,9 @@ 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,
|
||||
@@ -91,122 +84,118 @@ export function SecureVerificationDialog({
|
||||
(activeMethod === '2fa' && (!state.code.trim() || state.code.length < 6))
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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 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()
|
||||
}
|
||||
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>
|
||||
|
||||
<TabsContent value='2fa' className='space-y-3'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
<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(
|
||||
'Enter the 6-digit Time-based One-Time Password or 8-character backup code from your authenticator app.'
|
||||
'We will prompt your device to confirm using biometrics or your hardware key.'
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!methods.passkeySupported && (
|
||||
<p className='text-destructive text-sm'>
|
||||
{t('This device does not support Passkey verification.')}
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,14 +32,6 @@ 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,
|
||||
@@ -50,6 +42,7 @@ 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'
|
||||
@@ -414,43 +407,16 @@ export function UserAuthForm({
|
||||
<Dialog
|
||||
open={isWeChatDialogOpen}
|
||||
onOpenChange={handleWeChatDialogChange}
|
||||
>
|
||||
<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>
|
||||
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={
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
@@ -474,8 +440,32 @@ export function UserAuthForm({
|
||||
) : null}
|
||||
{t('Confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{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>
|
||||
</Dialog>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
@@ -26,14 +26,6 @@ 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,
|
||||
@@ -44,6 +36,7 @@ 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'
|
||||
@@ -387,43 +380,16 @@ export function SignUpForm({
|
||||
<Dialog
|
||||
open={isWeChatDialogOpen}
|
||||
onOpenChange={handleWeChatDialogChange}
|
||||
>
|
||||
<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>
|
||||
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={
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
@@ -447,8 +413,32 @@ export function SignUpForm({
|
||||
) : null}
|
||||
{t('Confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{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>
|
||||
</Dialog>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
formatTimestampToDate,
|
||||
formatQuota as formatQuotaValue,
|
||||
} from '@/lib/format'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { truncateText } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
@@ -46,8 +45,9 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { DataTableColumnHeader } from '@/components/data-table/column-header'
|
||||
import { DataTableColumnHeader } from '@/components/data-table'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { ProviderBadge } from '@/components/provider-badge'
|
||||
import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
|
||||
import { TableId } from '@/components/table-id'
|
||||
import { TruncatedText } from '@/components/truncated-text'
|
||||
@@ -623,7 +623,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
const typeNameKey = getChannelTypeLabel(type)
|
||||
const typeName = t(typeNameKey)
|
||||
const iconName = getChannelTypeIcon(type)
|
||||
const icon = getLobeIcon(`${iconName}.Color`, 14)
|
||||
const channel = row.original as Channel
|
||||
const isMultiKey = isMultiKeyChannel(channel)
|
||||
const multiKeyMode = channel.channel_info?.multi_key_mode ?? 'random'
|
||||
@@ -657,16 +656,12 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<StatusBadge
|
||||
autoColor={typeName}
|
||||
size='sm'
|
||||
<ProviderBadge
|
||||
iconKey={iconName}
|
||||
label={typeName}
|
||||
copyable={false}
|
||||
showDot={false}
|
||||
className='gap-1 pl-1'
|
||||
>
|
||||
{icon}
|
||||
<span className='truncate'>{typeName}</span>
|
||||
</StatusBadge>
|
||||
/>
|
||||
{isIonet && (
|
||||
<TooltipProvider delay={100}>
|
||||
<Tooltip>
|
||||
|
||||
+38
-62
@@ -16,20 +16,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getExpandedRowModel,
|
||||
type OnChangeFn,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
type ExpandedState,
|
||||
type Row,
|
||||
} from '@tanstack/react-table'
|
||||
import { useDebounce, useMediaQuery } from '@/hooks'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
@@ -38,6 +33,8 @@ import {
|
||||
DISABLED_ROW_DESKTOP,
|
||||
DISABLED_ROW_MOBILE,
|
||||
DataTablePage,
|
||||
useDebouncedColumnFilter,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { getChannels, searchChannels, getGroups } from '../api'
|
||||
import {
|
||||
@@ -81,12 +78,6 @@ export function ChannelsTable() {
|
||||
|
||||
// Table state
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
|
||||
models: false,
|
||||
tag: false,
|
||||
})
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [expanded, setExpanded] = useState<ExpandedState>({})
|
||||
|
||||
// URL state management
|
||||
const {
|
||||
@@ -116,35 +107,24 @@ export function ChannelsTable() {
|
||||
// Extract filters from column filters
|
||||
const statusFilter =
|
||||
(columnFilters.find((f) => f.id === 'status')?.value as string[]) || []
|
||||
const typeFilter =
|
||||
(columnFilters.find((f) => f.id === 'type')?.value as string[]) || []
|
||||
const typeFilter = useMemo(
|
||||
() => (columnFilters.find((f) => f.id === 'type')?.value as string[]) || [],
|
||||
[columnFilters]
|
||||
)
|
||||
const groupFilter =
|
||||
(columnFilters.find((f) => f.id === 'group')?.value as string[]) || []
|
||||
const modelFilterFromUrl =
|
||||
(columnFilters.find((f) => f.id === 'model')?.value as string) || ''
|
||||
|
||||
// Local state for immediate input feedback
|
||||
const [modelFilterInput, setModelFilterInput] = useState(modelFilterFromUrl)
|
||||
const debouncedModelFilter = useDebounce(modelFilterInput, 500)
|
||||
|
||||
// Sync local input with URL when URL changes (e.g., from back/forward navigation)
|
||||
useEffect(() => {
|
||||
setModelFilterInput(modelFilterFromUrl)
|
||||
}, [modelFilterFromUrl])
|
||||
|
||||
// Update URL when debounced value changes
|
||||
useEffect(() => {
|
||||
if (debouncedModelFilter !== modelFilterFromUrl) {
|
||||
onColumnFiltersChange((prev) => {
|
||||
const filtered = prev.filter((f) => f.id !== 'model')
|
||||
return debouncedModelFilter
|
||||
? [...filtered, { id: 'model', value: debouncedModelFilter }]
|
||||
: filtered
|
||||
})
|
||||
}
|
||||
}, [debouncedModelFilter, modelFilterFromUrl, onColumnFiltersChange])
|
||||
|
||||
const modelFilter = modelFilterFromUrl
|
||||
const {
|
||||
value: modelFilter,
|
||||
inputValue: modelFilterInput,
|
||||
onChange: onModelFilterInputChange,
|
||||
onCompositionStart: onModelFilterCompositionStart,
|
||||
onCompositionEnd: onModelFilterCompositionEnd,
|
||||
resetInput: resetModelFilterInput,
|
||||
} = useDebouncedColumnFilter({
|
||||
columnFilters,
|
||||
columnId: 'model',
|
||||
onColumnFiltersChange,
|
||||
})
|
||||
|
||||
// Determine whether to use search or regular list API
|
||||
const shouldSearch = Boolean(globalFilter?.trim() || modelFilter.trim())
|
||||
@@ -279,41 +259,31 @@ export function ChannelsTable() {
|
||||
const columns = useChannelsColumns()
|
||||
|
||||
// React Table instance
|
||||
const table = useReactTable({
|
||||
const { table } = useDataTable({
|
||||
data: channels,
|
||||
columns,
|
||||
pageCount: Math.ceil(totalCount / pagination.pageSize),
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
pagination,
|
||||
expanded,
|
||||
globalFilter,
|
||||
totalCount,
|
||||
sorting,
|
||||
initialColumnVisibility: {
|
||||
models: false,
|
||||
tag: false,
|
||||
},
|
||||
columnFilters,
|
||||
pagination,
|
||||
globalFilter,
|
||||
enableRowSelection: (row: Row<Channel>) => !isTagAggregateRow(row.original),
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: handleSortingChange,
|
||||
onColumnFiltersChange,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange,
|
||||
onExpandedChange: setExpanded,
|
||||
onGlobalFilterChange,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
getSubRows: (row: Channel & { children?: Channel[] }) => row.children,
|
||||
manualPagination: true,
|
||||
manualSorting: true,
|
||||
manualFiltering: true,
|
||||
withExpandedRowModel: true,
|
||||
ensurePageInRange,
|
||||
})
|
||||
|
||||
// Ensure page is in range when total count changes
|
||||
const pageCount = table.getPageCount()
|
||||
useEffect(() => {
|
||||
ensurePageInRange(pageCount)
|
||||
}, [pageCount, ensurePageInRange])
|
||||
|
||||
// Prepare filter options from existing channel types only.
|
||||
const typeFilterOptions = useMemo(() => {
|
||||
const counts = typeCounts || {}
|
||||
@@ -385,11 +355,17 @@ export function ChannelsTable() {
|
||||
applyHeaderSize
|
||||
toolbarProps={{
|
||||
searchPlaceholder: t('Filter by name, ID, or key...'),
|
||||
searchDebounceMs: 500,
|
||||
onReset: () => {
|
||||
resetModelFilterInput()
|
||||
},
|
||||
additionalSearch: (
|
||||
<Input
|
||||
placeholder={t('Filter by model...')}
|
||||
value={modelFilterInput}
|
||||
onChange={(e) => setModelFilterInput(e.target.value)}
|
||||
onChange={onModelFilterInputChange}
|
||||
onCompositionStart={onModelFilterCompositionStart}
|
||||
onCompositionEnd={onModelFilterCompositionEnd}
|
||||
className='w-full sm:w-[150px] lg:w-[180px]'
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -22,14 +22,6 @@ 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 {
|
||||
@@ -38,6 +30,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import {
|
||||
handleBatchDelete,
|
||||
handleBatchDisable,
|
||||
@@ -188,29 +181,21 @@ export function DataTableBulkActions<TData>({
|
||||
</BulkActionsToolbar>
|
||||
|
||||
{/* Set Tag Dialog */}
|
||||
<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>
|
||||
<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={
|
||||
<>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
@@ -221,22 +206,37 @@ export function DataTableBulkActions<TData>({
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSetTag}>{t('Set Tag')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<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>
|
||||
<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={
|
||||
<>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
@@ -246,8 +246,10 @@ export function DataTableBulkActions<TData>({
|
||||
<Button variant='destructive' onClick={handleDeleteAll}>
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{' '}
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
|
||||
+47
-52
@@ -24,14 +24,7 @@ import { toast } from 'sonner'
|
||||
import { formatCurrencyFromUSD } from '@/lib/currency'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { getCodexUsage, updateChannelBalance } from '../../api'
|
||||
import { channelsQueryKeys } from '../../lib'
|
||||
import { useChannels } from '../channels-provider'
|
||||
@@ -161,53 +154,55 @@ export function BalanceQueryDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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={
|
||||
<>
|
||||
<Button variant='outline' onClick={handleClose} disabled={isQuerying}>
|
||||
{t('Close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+150
-200
@@ -21,10 +21,6 @@ import {
|
||||
type ColumnDef,
|
||||
type RowSelectionState,
|
||||
type Table as TanStackTable,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { Check, Copy, Info, Loader2, Settings } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -33,14 +29,6 @@ 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 {
|
||||
@@ -60,21 +48,18 @@ import {
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
|
||||
import { DataTablePagination } from '@/components/data-table/pagination'
|
||||
import {
|
||||
DataTableBulkActions as BulkActionsToolbar,
|
||||
DataTablePagination,
|
||||
DataTableView,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import {
|
||||
sideDrawerContentClassName,
|
||||
sideDrawerFooterClassName,
|
||||
@@ -207,7 +192,7 @@ function getTestTableColumnClass(columnId: string) {
|
||||
case 'status':
|
||||
return 'w-70 min-w-70 max-w-70 whitespace-normal'
|
||||
case 'actions':
|
||||
return 'bg-popover sticky right-0 z-20 w-24 min-w-24 border-l shadow-[-8px_0_8px_-8px_rgb(0_0_0_/_0.2)] whitespace-nowrap sm:w-28 sm:min-w-28'
|
||||
return 'bg-popover w-24 min-w-24 whitespace-nowrap sm:w-28 sm:min-w-28'
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
@@ -234,6 +219,14 @@ export function ChannelTestDialog({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
const endpointSelectItems = useMemo(
|
||||
() =>
|
||||
endpointTypeOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.label),
|
||||
})),
|
||||
[t]
|
||||
)
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setEndpointType('auto')
|
||||
@@ -509,18 +502,17 @@ export function ChannelTestDialog({
|
||||
]
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
const { table } = useDataTable({
|
||||
data: tableData,
|
||||
columns,
|
||||
state: {
|
||||
rowSelection,
|
||||
pagination,
|
||||
},
|
||||
rowSelection,
|
||||
pagination,
|
||||
enableRowSelection: true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onPaginationChange: setPagination,
|
||||
withFilteredRowModel: false,
|
||||
withSortedRowModel: false,
|
||||
withFacetedRowModel: false,
|
||||
})
|
||||
|
||||
if (!currentRow) {
|
||||
@@ -529,179 +521,137 @@ export function ChannelTestDialog({
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<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={
|
||||
<>
|
||||
<Button variant='outline' onClick={handleClose}>
|
||||
{t('Close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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={endpointSelectItems}
|
||||
value={endpointType}
|
||||
onValueChange={(v) => v !== null && setEndpointType(v)}
|
||||
>
|
||||
<SelectTrigger id='endpoint-type'>
|
||||
<SelectValue placeholder={t('Auto detect (default)')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{endpointSelectItems.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{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'>
|
||||
<DataTableView
|
||||
table={table}
|
||||
containerClassName='rounded-md'
|
||||
containerProps={{
|
||||
role: 'region',
|
||||
'aria-label': t('Channel models'),
|
||||
}}
|
||||
tableContainerClassName='max-h-90 overflow-auto **:data-[slot=table-container]:overflow-visible'
|
||||
tableClassName='w-max min-w-full table-auto'
|
||||
pinnedColumns={[
|
||||
{
|
||||
columnId: 'actions',
|
||||
side: 'right',
|
||||
className: 'w-24 min-w-24 sm:w-28 sm:min-w-28',
|
||||
cellClassName: 'bg-popover',
|
||||
},
|
||||
]}
|
||||
colgroup={
|
||||
<colgroup>
|
||||
<col className='w-10 min-w-10' />
|
||||
<col className='w-auto' />
|
||||
<col className='w-70' />
|
||||
<col className='w-24 sm:w-28' />
|
||||
</colgroup>
|
||||
}
|
||||
getColumnClassName={(columnId) =>
|
||||
getTestTableColumnClass(columnId)
|
||||
}
|
||||
emptyContent={
|
||||
models.length
|
||||
? t('No models matched your search.')
|
||||
: t('This channel has no configured models.')
|
||||
}
|
||||
emptyCellClassName='text-muted-foreground h-16 text-center text-sm'
|
||||
/>
|
||||
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
|
||||
<TestModelsBulkActions
|
||||
table={table}
|
||||
disabled={isAnyTesting}
|
||||
onTestSelected={handleBatchTest}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
<FailureDetailsSheet
|
||||
details={failureDetails}
|
||||
|
||||
+75
-82
@@ -24,15 +24,8 @@ 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 = {
|
||||
@@ -129,78 +122,18 @@ export function CodexOAuthDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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={
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
@@ -215,8 +148,68 @@ export function CodexOAuthDialog({
|
||||
)}
|
||||
{state.isCompleting ? t('Generating...') : t('Generate credential')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+178
-181
@@ -31,16 +31,9 @@ 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 = {
|
||||
@@ -414,177 +407,23 @@ export function CodexUsageDialog({
|
||||
}, [response])
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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={
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
@@ -592,8 +431,166 @@ export function CodexUsageDialog({
|
||||
>
|
||||
{t('Close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+47
-50
@@ -22,16 +22,9 @@ 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'
|
||||
|
||||
@@ -74,45 +67,20 @@ export function CopyChannelDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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={
|
||||
<>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
@@ -122,10 +90,39 @@ export function CopyChannelDialog({
|
||||
</Button>
|
||||
<Button onClick={handleCopy} disabled={isCopying}>
|
||||
{isCopying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
|
||||
{isCopying ? 'Copying...' : 'Copy Channel'}
|
||||
{isCopying ? t('Copying...') : t('Copy Channel')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+216
-220
@@ -22,14 +22,6 @@ 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'
|
||||
@@ -43,6 +35,7 @@ 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 {
|
||||
@@ -222,216 +215,23 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
|
||||
if (!currentTag) return null
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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={
|
||||
<>
|
||||
<Button variant='outline' onClick={handleClose}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
@@ -439,8 +239,204 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
|
||||
{isSubmitting && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
|
||||
{t('Save Changes')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+143
-149
@@ -28,14 +28,6 @@ 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'
|
||||
@@ -44,6 +36,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { fetchUpstreamModels, updateChannel } from '../../api'
|
||||
import {
|
||||
channelsQueryKeys,
|
||||
@@ -365,152 +358,153 @@ export function FetchModelsDialog({
|
||||
)
|
||||
}
|
||||
|
||||
const showFooterActions =
|
||||
!!(activeChannel || customFetcher) &&
|
||||
!isFetching &&
|
||||
(fetchedModels.length > 0 || removedModels.length > 0)
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
{!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>
|
||||
) : (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={handleClose}
|
||||
title={t('Fetch Models')}
|
||||
description={
|
||||
activeChannel ? (
|
||||
<>
|
||||
<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'
|
||||
}
|
||||
>
|
||||
<TabsList
|
||||
className={`grid w-full ${removedModels.length > 0 ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
>
|
||||
<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>
|
||||
{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'
|
||||
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>
|
||||
{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>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={handleClose}
|
||||
disabled={isSaving}
|
||||
{/* 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'
|
||||
}
|
||||
>
|
||||
<TabsList
|
||||
className={`grid w-full ${removedModels.length > 0 ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
>
|
||||
{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>
|
||||
<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>
|
||||
{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'
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+202
-208
@@ -22,13 +22,6 @@ 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,
|
||||
@@ -38,15 +31,9 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
getMultiKeyStatus,
|
||||
@@ -228,215 +215,222 @@ export function MultiKeyManageDialog({
|
||||
|
||||
return (
|
||||
<>
|
||||
<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')}
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={
|
||||
<>
|
||||
{t('Multi-Key Management')}
|
||||
<StatusBadge
|
||||
label={currentRow.name}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
{currentRow.channel_info?.multi_key_mode && (
|
||||
<StatusBadge
|
||||
label={currentRow.name}
|
||||
label={
|
||||
currentRow.channel_info.multi_key_mode === 'random'
|
||||
? t('Random')
|
||||
: t('Polling')
|
||||
}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
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>
|
||||
|
||||
<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' />
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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)}
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => loadKeyStatus()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<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>
|
||||
<RefreshCw className='h-4 w-4' />
|
||||
</Button>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
{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>
|
||||
|
||||
{/* 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>
|
||||
) : (
|
||||
<StaticDataTable
|
||||
className='rounded-none border-0'
|
||||
tableClassName='min-w-[800px]'
|
||||
data={keys}
|
||||
getRowKey={(key) => key.index}
|
||||
columns={[
|
||||
{
|
||||
id: 'index',
|
||||
header: t('Index'),
|
||||
className: 'w-20',
|
||||
cellClassName: 'font-mono text-sm',
|
||||
cell: (key) => `#${key.index + 1}`,
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: t('Status'),
|
||||
className: 'w-32',
|
||||
cell: (key) => renderStatusBadge(key.status),
|
||||
},
|
||||
{
|
||||
id: 'reason',
|
||||
header: t('Disabled Reason'),
|
||||
className: 'min-w-[200px]',
|
||||
cellClassName: 'max-w-xs truncate text-sm',
|
||||
cell: (key) => key.reason || '-',
|
||||
},
|
||||
{
|
||||
id: 'disabled-time',
|
||||
header: t('Disabled Time'),
|
||||
className: 'w-44',
|
||||
cellClassName: 'text-muted-foreground text-sm',
|
||||
cell: (key) => formatKeyTimestamp(key.disabled_time),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'w-44 text-right',
|
||||
cell: (key) => (
|
||||
<MultiKeyTableRowActions
|
||||
keyIndex={key.index}
|
||||
status={key.status}
|
||||
onAction={setConfirmAction}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</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={() => loadKeyStatus()}
|
||||
disabled={isLoading}
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1 || isLoading}
|
||||
>
|
||||
<RefreshCw className='h-4 w-4' />
|
||||
{t('Previous')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages || isLoading}
|
||||
>
|
||||
{t('Next')}
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
|
||||
+192
-199
@@ -34,18 +34,11 @@ 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,
|
||||
@@ -375,203 +368,203 @@ export function OllamaModelsDialog({
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<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
|
||||
)
|
||||
<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>
|
||||
<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
|
||||
}
|
||||
/>
|
||||
)
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
</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)}
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</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>
|
||||
</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)}
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={close}>
|
||||
{t('Close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialog
|
||||
open={deleteOpen}
|
||||
|
||||
+346
-360
@@ -43,14 +43,6 @@ 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 {
|
||||
@@ -63,6 +55,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -1701,356 +1694,20 @@ export function ParamOverrideEditorDialog(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<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'>
|
||||
<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={
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
@@ -2061,8 +1718,337 @@ export function ParamOverrideEditorDialog(
|
||||
<Button type='button' onClick={handleSave}>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* 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 */}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+73
-77
@@ -21,16 +21,9 @@ 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
|
||||
@@ -84,73 +77,22 @@ export function StatusCodeRiskDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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={
|
||||
<>
|
||||
<Button variant='outline' onClick={handleCancel}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
@@ -161,8 +103,62 @@ export function StatusCodeRiskDialog({
|
||||
>
|
||||
{t('I confirm enabling high-risk retry')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+108
-112
@@ -23,18 +23,11 @@ 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,
|
||||
@@ -190,115 +183,118 @@ export function TagBatchEditDialog({
|
||||
if (!currentTag) return null
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
|
||||
</div>
|
||||
) : (
|
||||
<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 ? (
|
||||
<>
|
||||
<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)'
|
||||
)}
|
||||
/>
|
||||
<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.'
|
||||
)}
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('User groups that can access channels with this tag')}
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={handleClose}
|
||||
{/* 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}
|
||||
>
|
||||
{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>
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+143
-160
@@ -21,17 +21,11 @@ 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 {
|
||||
@@ -120,157 +114,15 @@ export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<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={
|
||||
<>
|
||||
<Button variant='outline' onClick={props.onCancel}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
@@ -284,8 +136,139 @@ export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
|
||||
>
|
||||
{t('Confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ export function Channels() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<ChannelsProvider>
|
||||
<SectionPageLayout>
|
||||
<SectionPageLayout fixedContent>
|
||||
<SectionPageLayout.Title>{t('Channels')}</SectionPageLayout.Title>
|
||||
<SectionPageLayout.Actions>
|
||||
<ChannelsPrimaryButtons />
|
||||
|
||||
+153
-168
@@ -21,15 +21,6 @@ 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,
|
||||
@@ -39,6 +30,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import {
|
||||
CONSUMPTION_DISTRIBUTION_CHART_OPTIONS,
|
||||
MODEL_ANALYTICS_CHART_OPTIONS,
|
||||
@@ -74,165 +66,158 @@ export function ModelsChartPreferences(props: ModelsChartPreferencesProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<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
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
+124
-134
@@ -23,15 +23,6 @@ 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'
|
||||
@@ -44,6 +35,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { DateTimePicker } from '@/components/datetime-picker'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import {
|
||||
TIME_GRANULARITY_OPTIONS,
|
||||
TIME_RANGE_PRESETS,
|
||||
@@ -144,129 +136,22 @@ export function ModelsFilter(props: ModelsFilterProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<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'>
|
||||
<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={
|
||||
<>
|
||||
<Button onClick={handleReset} variant='outline' type='button'>
|
||||
<RotateCcw className='mr-2 h-4 w-4' />
|
||||
{t('Reset')}
|
||||
@@ -275,8 +160,113 @@ export function ModelsFilter(props: ModelsFilterProps) {
|
||||
<Search className='mr-2 h-4 w-4' />
|
||||
{t('Apply Filters')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+33
-38
@@ -18,15 +18,9 @@ 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
|
||||
@@ -47,38 +41,39 @@ export function AnnouncementDetailModal({
|
||||
}: AnnouncementDetailModalProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
{announcement?.extra && (
|
||||
<div>
|
||||
<h4 className='mb-2 font-medium'>
|
||||
{t('Additional Information')}
|
||||
</h4>
|
||||
<Markdown className='text-muted-foreground'>
|
||||
{announcement.extra}
|
||||
</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -76,18 +76,18 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
|
||||
}, [resolvedFullKey, resolveRealKey, apiKey.id, markKeyCopied, t])
|
||||
|
||||
return (
|
||||
<div className='flex items-center'>
|
||||
<div className='flex max-w-full min-w-0 items-center'>
|
||||
<Popover open={popoverOpen} onOpenChange={handlePopoverOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='text-muted-foreground h-7 font-mono text-xs'
|
||||
className='text-muted-foreground h-7 max-w-full min-w-0 justify-start truncate px-0 font-mono text-xs hover:bg-transparent aria-expanded:bg-transparent'
|
||||
/>
|
||||
}
|
||||
>
|
||||
{maskedKey}
|
||||
<span className='truncate'>{maskedKey}</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className='w-auto max-w-[min(90vw,28rem)]'
|
||||
|
||||
@@ -92,6 +92,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
meta: { label: t('Select') },
|
||||
},
|
||||
{
|
||||
@@ -104,6 +105,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
{row.getValue('name')}
|
||||
</div>
|
||||
),
|
||||
size: 180,
|
||||
meta: { label: t('Name'), mobileTitle: true },
|
||||
},
|
||||
{
|
||||
@@ -123,6 +125,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
)
|
||||
},
|
||||
filterFn: (row, id, value) => value.includes(String(row.getValue(id))),
|
||||
size: 120,
|
||||
meta: { label: t('Status'), mobileBadge: true },
|
||||
},
|
||||
{
|
||||
@@ -131,6 +134,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
header: t('API Key'),
|
||||
cell: ({ row }) => <ApiKeyCell apiKey={row.original} />,
|
||||
enableSorting: false,
|
||||
size: 260,
|
||||
meta: { label: t('API Key') },
|
||||
},
|
||||
{
|
||||
@@ -189,6 +193,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
size: 170,
|
||||
meta: { label: t('Quota') },
|
||||
},
|
||||
{
|
||||
@@ -230,6 +235,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
}
|
||||
return <GroupBadge group={group} ratio={ratio} />
|
||||
},
|
||||
size: 160,
|
||||
meta: { label: t('Group'), mobileHidden: true },
|
||||
},
|
||||
{
|
||||
@@ -240,6 +246,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
),
|
||||
cell: ({ row }) => <ModelLimitsCell apiKey={row.original} />,
|
||||
enableSorting: false,
|
||||
size: 160,
|
||||
meta: { label: t('Models'), mobileHidden: true },
|
||||
},
|
||||
{
|
||||
@@ -250,6 +257,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
),
|
||||
cell: ({ row }) => <IpRestrictionsCell apiKey={row.original} />,
|
||||
enableSorting: false,
|
||||
size: 160,
|
||||
meta: { label: t('IP Restriction'), mobileHidden: true },
|
||||
},
|
||||
{
|
||||
@@ -258,10 +266,11 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
<DataTableColumnHeader column={column} title={t('Created')} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className='text-muted-foreground font-mono text-xs tabular-nums'>
|
||||
<span className='text-muted-foreground block truncate font-mono text-xs tabular-nums'>
|
||||
{formatTimestampToDate(row.getValue('created_time'))}
|
||||
</span>
|
||||
),
|
||||
size: 180,
|
||||
meta: { label: t('Created'), mobileHidden: true },
|
||||
},
|
||||
{
|
||||
@@ -275,11 +284,12 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
return (
|
||||
<span className='text-muted-foreground font-mono text-xs tabular-nums'>
|
||||
<span className='text-muted-foreground block truncate font-mono text-xs tabular-nums'>
|
||||
{formatTimestampToDate(accessedTime)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
meta: { label: t('Last Used'), mobileHidden: true },
|
||||
},
|
||||
{
|
||||
@@ -302,7 +312,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono text-xs tabular-nums',
|
||||
'block truncate font-mono text-xs tabular-nums',
|
||||
isExpired ? 'text-destructive' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
@@ -310,6 +320,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
</span>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
meta: { label: t('Expires'), mobileHidden: true },
|
||||
},
|
||||
{
|
||||
|
||||
+20
-62
@@ -16,21 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import {
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { useDebounce } from '@/hooks'
|
||||
import { type Table as TanstackTable } from '@tanstack/react-table'
|
||||
import { Database } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@@ -50,6 +38,8 @@ import {
|
||||
DISABLED_ROW_DESKTOP,
|
||||
DISABLED_ROW_MOBILE,
|
||||
DataTablePage,
|
||||
useDebouncedColumnFilter,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { getApiKeys, searchApiKeys } from '../api'
|
||||
@@ -99,7 +89,7 @@ function ApiKeysMobileList({
|
||||
table,
|
||||
isLoading,
|
||||
}: {
|
||||
table: ReturnType<typeof useReactTable<ApiKey>>
|
||||
table: TanstackTable<ApiKey>
|
||||
isLoading: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
@@ -192,9 +182,6 @@ export function ApiKeysTable() {
|
||||
const { t } = useTranslation()
|
||||
const { refreshTrigger } = useApiKeys()
|
||||
const columns = useApiKeysColumns()
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
|
||||
const {
|
||||
globalFilter,
|
||||
@@ -215,27 +202,15 @@ export function ApiKeysTable() {
|
||||
],
|
||||
})
|
||||
|
||||
const tokenFilterFromUrl =
|
||||
(columnFilters.find((f) => f.id === '_tokenSearch')?.value as string) || ''
|
||||
const [tokenFilterInput, setTokenFilterInput] = useState(tokenFilterFromUrl)
|
||||
const debouncedTokenFilter = useDebounce(tokenFilterInput, 500)
|
||||
|
||||
useEffect(() => {
|
||||
setTokenFilterInput(tokenFilterFromUrl)
|
||||
}, [tokenFilterFromUrl])
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedTokenFilter !== tokenFilterFromUrl) {
|
||||
onColumnFiltersChange((prev) => {
|
||||
const filtered = prev.filter((f) => f.id !== '_tokenSearch')
|
||||
return debouncedTokenFilter
|
||||
? [...filtered, { id: '_tokenSearch', value: debouncedTokenFilter }]
|
||||
: filtered
|
||||
})
|
||||
}
|
||||
}, [debouncedTokenFilter, tokenFilterFromUrl, onColumnFiltersChange])
|
||||
|
||||
const tokenFilter = tokenFilterFromUrl
|
||||
const {
|
||||
value: tokenFilter,
|
||||
inputValue: tokenFilterInput,
|
||||
setInputValue: setTokenFilterInput,
|
||||
} = useDebouncedColumnFilter({
|
||||
columnFilters,
|
||||
columnId: '_tokenSearch',
|
||||
onColumnFiltersChange,
|
||||
})
|
||||
const shouldSearch = Boolean(globalFilter?.trim() || tokenFilter.trim())
|
||||
|
||||
// Fetch data with React Query
|
||||
@@ -284,40 +259,22 @@ export function ApiKeysTable() {
|
||||
|
||||
const apiKeys = data?.items || []
|
||||
|
||||
const table = useReactTable({
|
||||
const { table } = useDataTable({
|
||||
data: apiKeys,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
pagination,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
pagination,
|
||||
globalFilterFn: () => true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
onPaginationChange,
|
||||
onGlobalFilterChange,
|
||||
onColumnFiltersChange,
|
||||
manualPagination: true,
|
||||
pageCount: Math.ceil((data?.total || 0) / pagination.pageSize),
|
||||
totalCount: data?.total || 0,
|
||||
ensurePageInRange,
|
||||
})
|
||||
|
||||
const pageCount = table.getPageCount()
|
||||
useEffect(() => {
|
||||
ensurePageInRange(pageCount)
|
||||
}, [pageCount, ensurePageInRange])
|
||||
|
||||
return (
|
||||
<DataTablePage
|
||||
table={table}
|
||||
@@ -329,6 +286,7 @@ export function ApiKeysTable() {
|
||||
'No API keys available. Create your first API key to get started.'
|
||||
)}
|
||||
skeletonKeyPrefix='api-keys-skeleton'
|
||||
applyHeaderSize
|
||||
toolbarProps={{
|
||||
searchPlaceholder: t('Filter by name...'),
|
||||
additionalSearch: (
|
||||
|
||||
@@ -23,15 +23,9 @@ 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: {
|
||||
@@ -151,76 +145,78 @@ export function CCSwitchDialog(props: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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={
|
||||
<>
|
||||
<Button variant='outline' onClick={() => props.onOpenChange(false)}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>{t('Open CC Switch')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ export function ApiKeys() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<ApiKeysProvider>
|
||||
<SectionPageLayout>
|
||||
<SectionPageLayout fixedContent>
|
||||
<SectionPageLayout.Title>{t('API Keys')}</SectionPageLayout.Title>
|
||||
<SectionPageLayout.Actions>
|
||||
<ApiKeysPrimaryButtons />
|
||||
|
||||
@@ -24,20 +24,13 @@ 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,
|
||||
@@ -187,19 +180,17 @@ export function DataTableBulkActions<TData>({
|
||||
</BulkActionsToolbar>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Delete Models?')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
'Are you sure you want to delete {{count}} model(s)? This action cannot be undone.',
|
||||
{ count: selectedIds.length }
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<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={
|
||||
<>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
@@ -209,8 +200,10 @@ export function DataTableBulkActions<TData>({
|
||||
<Button variant='destructive' onClick={handleDeleteAll}>
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{' '}
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -21,7 +21,7 @@ import { Eye, Info, Pencil, Settings2, Timer, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DataTableColumnHeader } from '@/components/data-table/column-header'
|
||||
import { DataTableColumnHeader } from '@/components/data-table'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { TableId } from '@/components/table-id'
|
||||
import { getDeploymentStatusConfig } from '../constants'
|
||||
|
||||
@@ -16,14 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type VisibilityState,
|
||||
} from '@tanstack/react-table'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@@ -38,7 +33,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { DataTablePage } from '@/components/data-table'
|
||||
import { DataTablePage, useDataTable } from '@/components/data-table'
|
||||
import { deleteDeployment, listDeployments, searchDeployments } from '../api'
|
||||
import { getDeploymentStatusOptions } from '../constants'
|
||||
import { deploymentsQueryKeys } from '../lib'
|
||||
@@ -167,8 +162,6 @@ export function DeploymentsTable() {
|
||||
}
|
||||
}
|
||||
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
|
||||
const columns = useDeploymentsColumns({
|
||||
onViewLogs: (id) => {
|
||||
setLogsDeploymentId(id)
|
||||
@@ -197,30 +190,22 @@ export function DeploymentsTable() {
|
||||
},
|
||||
})
|
||||
|
||||
const table = useReactTable({
|
||||
const { table } = useDataTable({
|
||||
data: deployments,
|
||||
columns,
|
||||
pageCount: Math.ceil(totalCount / pagination.pageSize),
|
||||
state: {
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
pagination,
|
||||
globalFilter,
|
||||
},
|
||||
totalCount,
|
||||
columnFilters,
|
||||
pagination,
|
||||
globalFilter,
|
||||
onColumnFiltersChange,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange,
|
||||
onGlobalFilterChange,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
manualFiltering: true,
|
||||
withSortedRowModel: false,
|
||||
ensurePageInRange,
|
||||
})
|
||||
|
||||
const pageCount = table.getPageCount()
|
||||
useEffect(() => {
|
||||
ensurePageInRange(pageCount)
|
||||
}, [ensurePageInRange, pageCount])
|
||||
|
||||
const statusFilterOptions = useMemo(() => {
|
||||
return [...getDeploymentStatusOptions(t)].map((opt) => ({
|
||||
label: opt.label,
|
||||
|
||||
+17
-22
@@ -17,14 +17,8 @@ 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
|
||||
@@ -41,21 +35,22 @@ export function DescriptionDialog({
|
||||
}: DescriptionDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='max-w-2xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{modelName}</DialogTitle>
|
||||
<DialogDescription>{t('Model Description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className='max-h-96'>
|
||||
<div className='space-y-2 pr-4'>
|
||||
<p className='text-foreground text-sm leading-relaxed break-words whitespace-pre-wrap'>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
<Dialog
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
+62
-65
@@ -22,15 +22,9 @@ 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'
|
||||
|
||||
@@ -164,62 +158,16 @@ export function ExtendDeploymentDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Extend deployment')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoadingDetails ? (
|
||||
<div className='flex items-center justify-center py-10'>
|
||||
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-4'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{t('Deployment ID')}:{' '}
|
||||
<span className='font-mono'>{deploymentId}</span>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<div className='text-sm font-medium'>{t('Duration (hours)')}</div>
|
||||
<Input
|
||||
type='number'
|
||||
min={1}
|
||||
value={hours}
|
||||
onChange={(e) => setHours(toInt(e.target.value, 1))}
|
||||
/>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t('This will extend the deployment by the specified hours.')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className='space-y-1'>
|
||||
<div className='text-sm font-medium'>{t('Estimated cost')}</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{isLoadingPrice || isFetchingPrice ? (
|
||||
<span className='inline-flex items-center gap-2'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
{t('Calculating...')}
|
||||
</span>
|
||||
) : priceParams ? (
|
||||
priceSummary || t('Not available')
|
||||
) : (
|
||||
t('Not available')
|
||||
)}
|
||||
</div>
|
||||
{!priceParams ? (
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t('Unable to estimate price for this deployment.')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className='mt-4'>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t('Extend deployment')}
|
||||
contentClassName='sm:max-w-lg'
|
||||
footerClassName='mt-4'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
@@ -229,8 +177,57 @@ export function ExtendDeploymentDialog({
|
||||
) : null}
|
||||
{t('Extend')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+121
-130
@@ -22,13 +22,6 @@ 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,
|
||||
@@ -37,6 +30,7 @@ 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'
|
||||
@@ -115,133 +109,130 @@ export function MissingModelsDialog({
|
||||
const showPagination = totalItems > pageSize
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className='flex max-h-[85vh] max-w-2xl flex-col gap-3 p-4'
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{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}
|
||||
{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='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 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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+282
-333
@@ -25,7 +25,6 @@ import {
|
||||
Plus,
|
||||
RefreshCcw,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@@ -40,14 +39,6 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
@@ -55,15 +46,9 @@ import {
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { TableId } from '@/components/table-id'
|
||||
import { deletePrefillGroup, getPrefillGroups } from '../../api'
|
||||
@@ -172,335 +157,299 @@ export function PrefillGroupManagementDialog({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className='prefill-dialog-content !top-4 !flex !-translate-y-0 !flex-col !gap-0 !border-none !bg-transparent !p-0 !shadow-none sm:!top-1/2 sm:!-translate-y-1/2'
|
||||
style={{ maxWidth: 'min(100vw, 64rem)' }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'prefill-dialog-panel border-border/70 bg-background flex max-h-[calc(100dvh-1.5rem)] flex-col overflow-hidden border shadow-2xl',
|
||||
isMobile ? 'rounded-none' : 'rounded-2xl'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex flex-col gap-3 border-b px-4 py-4 sm:px-6 sm:py-5',
|
||||
isMobile && 'pt-[calc(env(safe-area-inset-top,0px)+1rem)]'
|
||||
)}
|
||||
<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}
|
||||
>
|
||||
<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'>
|
||||
{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='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>
|
||||
{t(
|
||||
'Create reusable bundles of models, tags, endpoints, and user groups to speed up configuration elsewhere in the console.'
|
||||
'Create your first group to reuse model, tag, or endpoint selections anywhere in the dashboard.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<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='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>
|
||||
</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>
|
||||
) : 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>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
size='icon'
|
||||
variant='outline'
|
||||
onClick={() => onEditGroup(group)}
|
||||
>
|
||||
<Pencil className='h-4 w-4' />
|
||||
<span className='sr-only'>Edit group</span>
|
||||
</Button>
|
||||
<Button
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
className='text-destructive hover:text-destructive'
|
||||
onClick={() => handleDeleteClick(group)}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
<span className='sr-only'>Delete group</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
<div className='text-muted-foreground flex flex-wrap items-center gap-2 text-xs font-medium tracking-wide uppercase'>
|
||||
<span>Items</span>
|
||||
<StatusBadge
|
||||
label={`${parsedItems.length} item${parsedItems.length === 1 ? '' : 's'}`}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
</div>
|
||||
{parsedItems.length > 0 ? (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{parsedItems.slice(0, 6).map((item) => (
|
||||
<StatusBadge
|
||||
key={item}
|
||||
label={item}
|
||||
autoColor={item}
|
||||
size='sm'
|
||||
/>
|
||||
))}
|
||||
{parsedItems.length > 6 && (
|
||||
<StatusBadge
|
||||
label={`+${parsedItems.length - 6} more`}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{group.type === 'endpoint'
|
||||
? 'No endpoint mappings configured.'
|
||||
: 'No items configured yet.'}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<StaticDataTable
|
||||
tableClassName='min-w-[680px]'
|
||||
data={normalizedGroups}
|
||||
getRowKey={({ group }) => group.id}
|
||||
columns={[
|
||||
{
|
||||
id: 'group',
|
||||
header: t('Group'),
|
||||
cellClassName: 'align-top whitespace-normal',
|
||||
cell: ({ group }) => (
|
||||
<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>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
header: t('Type'),
|
||||
cellClassName: 'align-top',
|
||||
cell: ({ meta }) => (
|
||||
<StatusBadge
|
||||
label={meta.label}
|
||||
variant={meta.badge}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'items',
|
||||
header: t('Items'),
|
||||
className: 'min-w-[240px]',
|
||||
cellClassName: 'align-top whitespace-normal',
|
||||
cell: ({ group, parsedItems }) => (
|
||||
<>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{parsedItems.length > 0 ? (
|
||||
<>
|
||||
{parsedItems.slice(0, 6).map((item) => (
|
||||
<StatusBadge
|
||||
label={`${parsedItems.length} item${parsedItems.length === 1 ? '' : 's'}`}
|
||||
key={item}
|
||||
label={item}
|
||||
autoColor={item}
|
||||
size='sm'
|
||||
/>
|
||||
))}
|
||||
{parsedItems.length > 6 && (
|
||||
<StatusBadge
|
||||
label={`+${parsedItems.length - 6} more`}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
</div>
|
||||
{parsedItems.length > 0 ? (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{parsedItems.slice(0, 6).map((item) => (
|
||||
<StatusBadge
|
||||
key={item}
|
||||
label={item}
|
||||
autoColor={item}
|
||||
size='sm'
|
||||
/>
|
||||
))}
|
||||
{parsedItems.length > 6 && (
|
||||
<StatusBadge
|
||||
label={`+${parsedItems.length - 6} more`}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{group.type === 'endpoint'
|
||||
? 'No endpoint mappings configured.'
|
||||
: 'No items configured yet.'}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='rounded-md border'>
|
||||
<div className='w-full overflow-x-auto'>
|
||||
<Table className='min-w-[720px]'>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('Group')}</TableHead>
|
||||
<TableHead>{t('Type')}</TableHead>
|
||||
<TableHead className='min-w-[280px]'>
|
||||
{t('Items')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[120px] text-right'>
|
||||
{t('Actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{normalizedGroups.map(
|
||||
({ group, meta, parsedItems }) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className='align-top whitespace-normal'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<span className='font-medium'>
|
||||
{group.name}
|
||||
</span>
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'w-[120px] text-right',
|
||||
cellClassName: 'align-top',
|
||||
cell: ({ group }) => (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
+27
-30
@@ -22,14 +22,8 @@ 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'
|
||||
|
||||
@@ -111,27 +105,16 @@ export function RenameDeploymentDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='sm:max-w-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Rename deployment')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{t('Deployment ID')}:{' '}
|
||||
<span className='font-mono'>{deploymentId}</span>
|
||||
</div>
|
||||
<Input
|
||||
placeholder={t('Enter a new name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoComplete='off'
|
||||
/>
|
||||
<div className='text-muted-foreground text-xs'>{helper}</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className='mt-4'>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t('Rename deployment')}
|
||||
contentClassName='sm:max-w-lg'
|
||||
footerClassName='mt-4'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
@@ -141,8 +124,22 @@ export function RenameDeploymentDialog({
|
||||
) : null}
|
||||
{t('Rename')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+110
-122
@@ -24,16 +24,9 @@ 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'
|
||||
@@ -125,117 +118,16 @@ export function SyncWizardDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className='flex max-h-[90vh] w-full flex-col gap-4 p-4 sm:max-w-2xl sm:p-6'
|
||||
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'>
|
||||
<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={
|
||||
<>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
@@ -246,10 +138,106 @@ 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 ? 'Syncing...' : 'Sync Now'}
|
||||
{isSyncing ? t('Syncing...') : t('Sync Now')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+212
-214
@@ -30,13 +30,6 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -47,6 +40,7 @@ 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'
|
||||
|
||||
@@ -64,6 +58,8 @@ 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)
|
||||
@@ -212,226 +208,228 @@ export function UpdateConfigDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
<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'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='command'
|
||||
name='image_url'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Command')}</FormLabel>
|
||||
<FormLabel>{t('Image')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='Optional' {...field} />
|
||||
<Input placeholder='ollama/ollama:latest' {...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>
|
||||
<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('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>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+179
-232
@@ -18,13 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type RowSelectionState,
|
||||
} from '@tanstack/react-table'
|
||||
import { type ColumnDef, type RowSelectionState } from '@tanstack/react-table'
|
||||
import {
|
||||
Search,
|
||||
Info,
|
||||
@@ -37,14 +31,6 @@ 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,14 +45,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { DataTableView, useDataTable } from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { applyUpstreamOverwrite } from '../../api'
|
||||
import { modelsQueryKeys, vendorsQueryKeys } from '../../lib'
|
||||
@@ -85,6 +65,8 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
enable_groups: 'Enable Groups',
|
||||
}
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 20, 50] as const
|
||||
|
||||
const formatValue = (value: unknown) => {
|
||||
if (value === null || value === undefined) return '—'
|
||||
if (typeof value === 'string') return value || '—'
|
||||
@@ -348,16 +330,17 @@ export function UpstreamConflictDialog({
|
||||
]
|
||||
}, [isMobile])
|
||||
|
||||
const table = useReactTable({
|
||||
const { table } = useDataTable({
|
||||
data: conflictRows,
|
||||
columns,
|
||||
state: {
|
||||
rowSelection,
|
||||
},
|
||||
rowSelection,
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
withFilteredRowModel: false,
|
||||
withPaginationRowModel: false,
|
||||
withSortedRowModel: false,
|
||||
withFacetedRowModel: false,
|
||||
})
|
||||
|
||||
const totalSelectedFields = table.getSelectedRowModel().rows.length
|
||||
@@ -453,222 +436,186 @@ export function UpstreamConflictDialog({
|
||||
}
|
||||
onOpenChange(nextOpen)
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
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(
|
||||
'Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.'
|
||||
'Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!hasConflicts ? (
|
||||
<div className='text-muted-foreground flex flex-1 items-center justify-center rounded-md border border-dashed p-8 text-center text-sm'>
|
||||
{t('No conflict entries available.')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex min-h-0 flex-1 flex-col gap-4 overflow-hidden'>
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='space-y-1'>
|
||||
<div className='text-sm font-medium'>
|
||||
{visibleModelCount} {t('model')}
|
||||
{visibleModelCount === 1 ? '' : 's'} {t('with conflicts')}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{visibleFieldCount} {t('field')}
|
||||
{visibleFieldCount === 1 ? '' : 's'} {t('showing •')}{' '}
|
||||
{totalSelectedFields} {t('selected')}
|
||||
</div>
|
||||
</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='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 className='text-muted-foreground text-xs'>
|
||||
{visibleFieldCount} {t('field')}
|
||||
{visibleFieldCount === 1 ? '' : 's'} {t('showing •')}{' '}
|
||||
{totalSelectedFields} {t('selected')}
|
||||
</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 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>
|
||||
) : (
|
||||
<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>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={clearSelections}
|
||||
disabled={!hasSelection}
|
||||
>
|
||||
{t('Clear selection')}
|
||||
</Button>
|
||||
</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'}
|
||||
{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'>
|
||||
<DataTableView
|
||||
table={table}
|
||||
rows={paginatedRows}
|
||||
containerClassName='border-0'
|
||||
tableContainerClassName={
|
||||
isMobile ? 'min-w-full' : 'min-w-[720px]'
|
||||
}
|
||||
/>
|
||||
</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={PAGE_SIZE_OPTIONS.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>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<SelectItem key={size} value={String(size)}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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 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>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+102
-98
@@ -24,14 +24,6 @@ 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,
|
||||
@@ -43,6 +35,7 @@ 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'
|
||||
@@ -53,6 +46,8 @@ type VendorMutateDialogProps = {
|
||||
currentVendor?: Vendor | null
|
||||
}
|
||||
|
||||
const VENDOR_MUTATE_FORM_ID = 'vendor-mutate-form'
|
||||
|
||||
export function VendorMutateDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -118,98 +113,107 @@ export function VendorMutateDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit ? t('Edit Vendor') : t('Create Vendor')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? t('Update vendor information for {{name}}', {
|
||||
name: currentVendor?.name,
|
||||
})
|
||||
: t('Add a new vendor to the system')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='name'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Vendor Name *')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('OpenAI, Anthropic, etc.')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('The unique name for this vendor')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='description'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t('Describe this vendor...')}
|
||||
rows={3}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='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>
|
||||
<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>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+155
-163
@@ -27,14 +27,8 @@ 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({
|
||||
@@ -116,160 +110,15 @@ export function ViewDetailsDialog({
|
||||
}, [details])
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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={
|
||||
<>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
@@ -277,8 +126,151 @@ export function ViewDetailsDialog({
|
||||
>
|
||||
{t('Close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+166
-171
@@ -21,12 +21,6 @@ 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,
|
||||
@@ -36,6 +30,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { getDeploymentLogs, listDeploymentContainers } from '../../api'
|
||||
|
||||
interface ViewLogsDialogProps {
|
||||
@@ -142,180 +137,180 @@ export function ViewLogsDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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} />
|
||||
</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) => {
|
||||
</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) => {
|
||||
const id = c?.container_id
|
||||
if (typeof id !== 'string' || !id) return []
|
||||
if (typeof id !== 'string' || !id) return null
|
||||
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) => {
|
||||
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>
|
||||
return (
|
||||
<SelectItem key={id} value={id}>
|
||||
{id}
|
||||
{status}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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 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>
|
||||
</DialogContent>
|
||||
</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>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+15
-15
@@ -27,8 +27,9 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { DataTableColumnHeader } from '@/components/data-table/column-header'
|
||||
import { DataTableColumnHeader } from '@/components/data-table'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { ProviderBadge } from '@/components/provider-badge'
|
||||
import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
|
||||
import { TableId } from '@/components/table-id'
|
||||
import {
|
||||
@@ -41,6 +42,12 @@ import type { Model, Vendor } from '../types'
|
||||
import { DataTableRowActions } from './data-table-row-actions'
|
||||
import { DescriptionCell } from './description-cell'
|
||||
|
||||
function getCompactModelIcon(iconKey: string) {
|
||||
const baseIconKey = iconKey.split('.')[0]
|
||||
|
||||
return getLobeIcon(`${baseIconKey}.Avatar.type={'platform'}`, 20)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render limited items with "and X more" indicator
|
||||
*/
|
||||
@@ -123,9 +130,13 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
vendorMap[model.vendor_id || 0]?.icon ||
|
||||
model.model_name?.[0] ||
|
||||
'N'
|
||||
const icon = getLobeIcon(iconKey, 20)
|
||||
const icon = getCompactModelIcon(iconKey)
|
||||
|
||||
return <div className='flex items-center justify-center'>{icon}</div>
|
||||
return (
|
||||
<div className='ms-1 flex size-5 items-center justify-center overflow-hidden'>
|
||||
{icon}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 70,
|
||||
enableSorting: false,
|
||||
@@ -259,18 +270,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
const icon = vendor.icon ? getLobeIcon(vendor.icon, 14) : null
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-1.5'>
|
||||
{icon}
|
||||
<StatusBadge
|
||||
label={vendor.name}
|
||||
autoColor={vendor.name}
|
||||
size='sm'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
return <ProviderBadge iconKey={vendor.icon} label={vendor.name} />
|
||||
},
|
||||
filterFn: (row, id, value) => {
|
||||
if (!value || value.length === 0 || value.includes('all')) return true
|
||||
|
||||
+12
-36
@@ -16,19 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
} from '@tanstack/react-table'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
import { DataTablePage } from '@/components/data-table'
|
||||
import { DataTablePage, useDataTable } from '@/components/data-table'
|
||||
import { getModels, searchModels, getVendors } from '../api'
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
@@ -47,15 +41,6 @@ export function ModelsTable() {
|
||||
const { selectedVendor } = useModels()
|
||||
const isMobile = useMediaQuery('(max-width: 640px)')
|
||||
|
||||
// Table state
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
|
||||
description: false,
|
||||
bound_channels: false,
|
||||
quota_types: false,
|
||||
})
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
|
||||
// URL state management
|
||||
const {
|
||||
globalFilter,
|
||||
@@ -176,37 +161,28 @@ export function ModelsTable() {
|
||||
const columns = useModelsColumns(vendors)
|
||||
|
||||
// React Table instance
|
||||
const table = useReactTable({
|
||||
const { table } = useDataTable({
|
||||
data: models,
|
||||
columns,
|
||||
pageCount: Math.ceil(totalCount / pagination.pageSize),
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
pagination,
|
||||
globalFilter,
|
||||
totalCount,
|
||||
initialColumnVisibility: {
|
||||
description: false,
|
||||
bound_channels: false,
|
||||
quota_types: false,
|
||||
},
|
||||
columnFilters,
|
||||
pagination,
|
||||
globalFilter,
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange,
|
||||
onGlobalFilterChange,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
manualSorting: true,
|
||||
manualFiltering: true,
|
||||
ensurePageInRange,
|
||||
})
|
||||
|
||||
// Ensure page is in range when total count changes
|
||||
const pageCount = table.getPageCount()
|
||||
useEffect(() => {
|
||||
ensurePageInRange(pageCount)
|
||||
}, [pageCount, ensurePageInRange])
|
||||
|
||||
// Prepare filter options
|
||||
const vendorFilterOptions = [
|
||||
{
|
||||
|
||||
+19
-17
@@ -119,7 +119,7 @@ function ModelsContent() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionPageLayout>
|
||||
<SectionPageLayout fixedContent>
|
||||
<SectionPageLayout.Title>{t(meta.titleKey)}</SectionPageLayout.Title>
|
||||
<SectionPageLayout.Actions>
|
||||
{activeSection === 'metadata' ? (
|
||||
@@ -132,7 +132,7 @@ function ModelsContent() {
|
||||
)}
|
||||
</SectionPageLayout.Actions>
|
||||
<SectionPageLayout.Content>
|
||||
<div className='space-y-4'>
|
||||
<div className='flex h-full min-h-0 flex-col gap-4'>
|
||||
<Tabs value={activeSection} onValueChange={handleSectionChange}>
|
||||
<TabsList className='max-w-full flex-wrap justify-start group-data-horizontal/tabs:h-auto'>
|
||||
{MODELS_SECTION_IDS.map((section) => (
|
||||
@@ -142,21 +142,23 @@ function ModelsContent() {
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{activeSection === 'metadata' ? (
|
||||
<ModelsTable />
|
||||
) : (
|
||||
<DeploymentAccessGuard
|
||||
loading={deploymentLoading}
|
||||
loadingPhase={loadingPhase}
|
||||
isEnabled={isIoNetEnabled}
|
||||
connectionLoading={connectionLoading}
|
||||
connectionOk={connectionOk}
|
||||
connectionError={connectionError}
|
||||
onRetry={testConnection}
|
||||
>
|
||||
<DeploymentsTable />
|
||||
</DeploymentAccessGuard>
|
||||
)}
|
||||
<div className='min-h-0 flex-1'>
|
||||
{activeSection === 'metadata' ? (
|
||||
<ModelsTable />
|
||||
) : (
|
||||
<DeploymentAccessGuard
|
||||
loading={deploymentLoading}
|
||||
loadingPhase={loadingPhase}
|
||||
isEnabled={isIoNetEnabled}
|
||||
connectionLoading={connectionLoading}
|
||||
connectionOk={connectionOk}
|
||||
connectionError={connectionError}
|
||||
onRetry={testConnection}
|
||||
>
|
||||
<DeploymentsTable />
|
||||
</DeploymentAccessGuard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SectionPageLayout.Content>
|
||||
</SectionPageLayout>
|
||||
|
||||
+68
-79
@@ -22,14 +22,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useSystemConfigStore } from '@/stores/system-config-store'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import {
|
||||
BILLING_PRICING_VARS,
|
||||
MATCH_CONTAINS,
|
||||
@@ -307,86 +300,82 @@ export function DynamicPricingBreakdown({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className='hidden overflow-x-auto sm:block'>
|
||||
<Table className='text-sm'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className='text-muted-foreground py-2 font-medium'>
|
||||
{t('Tier')}
|
||||
</TableHead>
|
||||
{visiblePriceFields.map((v) => (
|
||||
<TableHead
|
||||
key={v.field}
|
||||
className='text-muted-foreground py-2 text-right font-medium'
|
||||
>
|
||||
{t(v.shortLabel)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tiers.map((tier, i) => {
|
||||
const condSummary = formatConditionSummary(tier.conditions, t)
|
||||
<StaticDataTable
|
||||
className='hidden rounded-none border-0 sm:block'
|
||||
tableClassName='text-sm'
|
||||
headerRowClassName='hover:bg-transparent'
|
||||
data={tiers}
|
||||
getRowKey={(_tier, index) => `tier-${index}`}
|
||||
getRowClassName={(tier) => {
|
||||
const isMatched =
|
||||
normalizedMatchedTierLabel !== '' &&
|
||||
normalizeTierLabel(tier.label) === normalizedMatchedTierLabel
|
||||
return cn(
|
||||
isMatched &&
|
||||
'bg-emerald-50/70 hover:bg-emerald-50/70 dark:bg-emerald-500/10 dark:hover:bg-emerald-500/10'
|
||||
)
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
id: 'tier',
|
||||
header: t('Tier'),
|
||||
className: 'text-muted-foreground py-2 font-medium',
|
||||
cellClassName: 'py-2.5 align-top',
|
||||
cell: (tier) => {
|
||||
const condSummary = formatConditionSummary(
|
||||
tier.conditions,
|
||||
t
|
||||
)
|
||||
const isMatched =
|
||||
normalizedMatchedTierLabel !== '' &&
|
||||
normalizeTierLabel(tier.label) ===
|
||||
normalizedMatchedTierLabel
|
||||
normalizeTierLabel(tier.label) === normalizedMatchedTierLabel
|
||||
return (
|
||||
<TableRow
|
||||
key={`tier-${i}`}
|
||||
className={cn(
|
||||
isMatched &&
|
||||
'bg-emerald-50/70 hover:bg-emerald-50/70 dark:bg-emerald-500/10 dark:hover:bg-emerald-500/10'
|
||||
)}
|
||||
>
|
||||
<TableCell className='py-2.5 align-top'>
|
||||
<div className='flex flex-wrap items-center gap-1.5'>
|
||||
<>
|
||||
<div className='flex flex-wrap items-center gap-1.5'>
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
|
||||
>
|
||||
{tier.label || t('Default')}
|
||||
</Badge>
|
||||
{isMatched && (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
|
||||
className='bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'
|
||||
>
|
||||
{tier.label || t('Default')}
|
||||
{t('Matched')}
|
||||
</Badge>
|
||||
{isMatched && (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'
|
||||
>
|
||||
{t('Matched')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{condSummary && (
|
||||
<div className='text-muted-foreground mt-1 text-xs'>
|
||||
{condSummary}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
{visiblePriceFields.map((v) => {
|
||||
const value = Number(
|
||||
tier[v.field as string as keyof ParsedTier] || 0
|
||||
)
|
||||
return (
|
||||
<TableCell
|
||||
key={v.field}
|
||||
className='py-2.5 text-right align-top font-mono'
|
||||
>
|
||||
{value > 0 ? (
|
||||
<span className='font-semibold'>
|
||||
{`${symbol}${(value * rate).toFixed(4)}`}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
</div>
|
||||
{condSummary && (
|
||||
<div className='text-muted-foreground mt-1 text-xs'>
|
||||
{condSummary}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
},
|
||||
},
|
||||
...visiblePriceFields.map((v, index) => ({
|
||||
id: v.field ?? `price-${index}`,
|
||||
header: t(v.shortLabel),
|
||||
className: 'text-muted-foreground py-2 text-right font-medium',
|
||||
cellClassName: 'py-2.5 text-right align-top font-mono',
|
||||
cell: (tier: ParsedTier) => {
|
||||
const value = Number(
|
||||
tier[v.field as string as keyof ParsedTier] || 0
|
||||
)
|
||||
return value > 0 ? (
|
||||
<span className='font-semibold'>
|
||||
{`${symbol}${(value * rate).toFixed(4)}`}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)
|
||||
},
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -32,19 +32,15 @@ import type { BundledLanguage } from 'shiki/bundle/web'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
CodeBlock,
|
||||
CodeBlockCopyButton,
|
||||
} from '@/components/ai-elements/code-block'
|
||||
import {
|
||||
StaticDataTable,
|
||||
staticDataTableClassNames as tableStyles,
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
buildRateLimits,
|
||||
buildSupportedParameters,
|
||||
@@ -570,53 +566,62 @@ function SupportedParametersSection(props: { model: PricingModel }) {
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle icon={Sigma}>{t('Supported parameters')}</SectionTitle>
|
||||
<div className='border-border/60 overflow-hidden rounded-lg border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className='bg-muted/30 hover:bg-muted/30'>
|
||||
<TableHead className='h-9 w-44'>{t('Parameter')}</TableHead>
|
||||
<TableHead className='h-9 w-24'>{t('Type')}</TableHead>
|
||||
<TableHead className='h-9 w-32'>{t('Default / range')}</TableHead>
|
||||
<TableHead className='h-9'>{t('Description')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{params.map((p) => (
|
||||
<TableRow key={p.name} className='hover:bg-muted/20'>
|
||||
<TableCell className='py-2 align-top'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<code className='font-mono text-sm font-medium'>
|
||||
{p.name}
|
||||
</code>
|
||||
{p.required && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='h-6 border-rose-500/40 px-2 text-sm text-rose-600 dark:text-rose-400'
|
||||
>
|
||||
{t('required')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='py-2 align-top'>
|
||||
<StaticDataTable
|
||||
className={tableStyles.sectionContainer}
|
||||
headerRowClassName={tableStyles.mutedHeaderRow}
|
||||
data={params}
|
||||
getRowKey={(param) => param.name}
|
||||
getRowClassName={() => 'hover:bg-muted/20'}
|
||||
columns={[
|
||||
{
|
||||
id: 'parameter',
|
||||
header: t('Parameter'),
|
||||
className: 'h-9 w-44',
|
||||
cellClassName: tableStyles.topCell,
|
||||
cell: (p) => (
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<code className='font-mono text-sm font-medium'>{p.name}</code>
|
||||
{p.required && (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='h-7 rounded-full px-2.5 font-mono text-sm font-normal'
|
||||
variant='outline'
|
||||
className='h-6 border-rose-500/40 px-2 text-sm text-rose-600 dark:text-rose-400'
|
||||
>
|
||||
{p.type}
|
||||
{t('required')}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='py-2 align-top'>
|
||||
<ParamRangeCell param={p} />
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground py-2 align-top'>
|
||||
{t(p.descriptionKey)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
header: t('Type'),
|
||||
className: 'h-9 w-24',
|
||||
cellClassName: tableStyles.topCell,
|
||||
cell: (p) => (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='h-7 rounded-full px-2.5 font-mono text-sm font-normal'
|
||||
>
|
||||
{p.type}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'range',
|
||||
header: t('Default / range'),
|
||||
className: 'h-9 w-32',
|
||||
cellClassName: tableStyles.topCell,
|
||||
cell: (p) => <ParamRangeCell param={p} />,
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
header: t('Description'),
|
||||
className: 'h-9',
|
||||
cellClassName: tableStyles.topMutedCell,
|
||||
cell: (p) => t(p.descriptionKey),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -671,34 +676,43 @@ function RateLimitsSection(props: { model: PricingModel }) {
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle icon={Gauge}>{t('Rate limits')}</SectionTitle>
|
||||
<div className='border-border/60 overflow-hidden rounded-lg border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className='bg-muted/30 hover:bg-muted/30'>
|
||||
<TableHead className='h-9'>{t('Group')}</TableHead>
|
||||
<TableHead className='h-9 text-right'>RPM</TableHead>
|
||||
<TableHead className='h-9 text-right'>TPM</TableHead>
|
||||
<TableHead className='h-9 text-right'>RPD</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{limits.map((l) => (
|
||||
<TableRow key={l.group} className='hover:bg-muted/20'>
|
||||
<TableCell className='py-2 font-mono'>{l.group}</TableCell>
|
||||
<TableCell className='py-2 text-right font-mono'>
|
||||
{formatRateLimit(l.rpm)}
|
||||
</TableCell>
|
||||
<TableCell className='py-2 text-right font-mono'>
|
||||
{formatRateLimit(l.tpm)}
|
||||
</TableCell>
|
||||
<TableCell className='py-2 text-right font-mono'>
|
||||
{formatRateLimit(l.rpd)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<StaticDataTable
|
||||
className={tableStyles.sectionContainer}
|
||||
headerRowClassName={tableStyles.mutedHeaderRow}
|
||||
data={limits}
|
||||
getRowKey={(limit) => limit.group}
|
||||
getRowClassName={() => 'hover:bg-muted/20'}
|
||||
columns={[
|
||||
{
|
||||
id: 'group',
|
||||
header: t('Group'),
|
||||
className: 'h-9',
|
||||
cellClassName: 'py-2 font-mono',
|
||||
cell: (limit) => limit.group,
|
||||
},
|
||||
{
|
||||
id: 'rpm',
|
||||
header: 'RPM',
|
||||
className: 'h-9 text-right',
|
||||
cellClassName: tableStyles.topNumericCell,
|
||||
cell: (limit) => formatRateLimit(limit.rpm),
|
||||
},
|
||||
{
|
||||
id: 'tpm',
|
||||
header: 'TPM',
|
||||
className: 'h-9 text-right',
|
||||
cellClassName: tableStyles.topNumericCell,
|
||||
cell: (limit) => formatRateLimit(limit.tpm),
|
||||
},
|
||||
{
|
||||
id: 'rpd',
|
||||
header: 'RPD',
|
||||
className: 'h-9 text-right',
|
||||
cellClassName: tableStyles.topNumericCell,
|
||||
cell: (limit) => formatRateLimit(limit.rpd),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<p className='text-muted-foreground mt-2 text-[11px] leading-relaxed'>
|
||||
{t(
|
||||
'RPM = requests per minute, TPM = tokens per minute, RPD = requests per day. Limits apply per token group.'
|
||||
|
||||
@@ -26,13 +26,9 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
StaticDataTable,
|
||||
staticDataTableClassNames as tableStyles,
|
||||
} from '@/components/data-table'
|
||||
import {
|
||||
buildAppRankings,
|
||||
formatTokenVolume,
|
||||
@@ -123,9 +119,6 @@ export function ModelDetailsApps(props: { model: PricingModel }) {
|
||||
|
||||
const totalMonthlyTokens = apps.reduce((s, a) => s + a.monthly_tokens, 0)
|
||||
const top = apps[0]
|
||||
const headerCellClass =
|
||||
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase'
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='grid grid-cols-1 gap-2 sm:grid-cols-3'>
|
||||
@@ -165,60 +158,70 @@ export function ModelDetailsApps(props: { model: PricingModel }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='overflow-x-auto rounded-lg border'>
|
||||
<Table className='text-sm'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className={cn(headerCellClass, 'w-12')}>#</TableHead>
|
||||
<TableHead className={headerCellClass}>{t('App')}</TableHead>
|
||||
<TableHead
|
||||
className={cn(headerCellClass, 'hidden md:table-cell')}
|
||||
>
|
||||
{t('Category')}
|
||||
</TableHead>
|
||||
<TableHead className={`${headerCellClass} text-right`}>
|
||||
{t('Monthly tokens')}
|
||||
</TableHead>
|
||||
<TableHead className={`${headerCellClass} text-right`}>
|
||||
{t('30d change')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apps.map((app) => (
|
||||
<TableRow key={`${app.rank}-${app.name}`}>
|
||||
<TableCell className='py-2.5'>
|
||||
<RankBadge rank={app.rank} />
|
||||
</TableCell>
|
||||
<TableCell className='py-2.5'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='bg-muted text-muted-foreground inline-flex size-7 shrink-0 items-center justify-center rounded-md font-bold'>
|
||||
{app.initial}
|
||||
</span>
|
||||
<div className='min-w-0'>
|
||||
<div className='text-sm font-medium'>
|
||||
<AppLink app={app} />
|
||||
</div>
|
||||
<p className='text-muted-foreground line-clamp-1 text-sm'>
|
||||
{app.description}
|
||||
</p>
|
||||
</div>
|
||||
<StaticDataTable
|
||||
className='rounded-lg'
|
||||
tableClassName='text-sm'
|
||||
headerRowClassName={tableStyles.compactHeaderRow}
|
||||
data={apps}
|
||||
getRowKey={(app) => `${app.rank}-${app.name}`}
|
||||
columns={[
|
||||
{
|
||||
id: 'rank',
|
||||
header: '#',
|
||||
className: cn(tableStyles.compactHeaderCell, 'w-12'),
|
||||
cellClassName: tableStyles.compactCell,
|
||||
cell: (app) => <RankBadge rank={app.rank} />,
|
||||
},
|
||||
{
|
||||
id: 'app',
|
||||
header: t('App'),
|
||||
className: tableStyles.compactHeaderCell,
|
||||
cellClassName: tableStyles.compactCell,
|
||||
cell: (app) => (
|
||||
<div className='flex items-center gap-3'>
|
||||
<span className='bg-muted text-muted-foreground inline-flex size-7 shrink-0 items-center justify-center rounded-md font-bold'>
|
||||
{app.initial}
|
||||
</span>
|
||||
<div className='min-w-0'>
|
||||
<div className='text-sm font-medium'>
|
||||
<AppLink app={app} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground hidden py-2.5 md:table-cell'>
|
||||
{app.category}
|
||||
</TableCell>
|
||||
<TableCell className='py-2.5 text-right font-mono tabular-nums'>
|
||||
{formatTokenVolume(app.monthly_tokens)}
|
||||
</TableCell>
|
||||
<TableCell className='py-2.5 text-right'>
|
||||
<GrowthChip value={app.growth_pct} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<p className='text-muted-foreground line-clamp-1 text-sm'>
|
||||
{app.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'category',
|
||||
header: t('Category'),
|
||||
className: cn(
|
||||
tableStyles.compactHeaderCell,
|
||||
'hidden md:table-cell'
|
||||
),
|
||||
cellClassName: cn(
|
||||
tableStyles.compactMutedCell,
|
||||
'hidden md:table-cell'
|
||||
),
|
||||
cell: (app) => app.category,
|
||||
},
|
||||
{
|
||||
id: 'monthly-tokens',
|
||||
header: t('Monthly tokens'),
|
||||
className: tableStyles.compactHeaderCellRight,
|
||||
cellClassName: cn(tableStyles.compactNumericCell, 'tabular-nums'),
|
||||
cell: (app) => formatTokenVolume(app.monthly_tokens),
|
||||
},
|
||||
{
|
||||
id: 'growth',
|
||||
header: t('30d change'),
|
||||
className: tableStyles.compactHeaderCellRight,
|
||||
cellClassName: cn(tableStyles.compactCell, 'text-right'),
|
||||
cell: (app) => <GrowthChip value={app.growth_pct} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<p className='text-muted-foreground/60 text-[11px] leading-relaxed'>
|
||||
{t(
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import type { Modality } from '../types'
|
||||
|
||||
type IconComponent = React.ComponentType<{ className?: string }>
|
||||
@@ -95,79 +96,65 @@ export function ModalitiesMatrix(props: {
|
||||
const inputSet = new Set(props.input)
|
||||
const outputSet = new Set(props.output)
|
||||
|
||||
const renderRow = (label: string, set: Set<Modality>) => (
|
||||
<tr>
|
||||
<th
|
||||
scope='row'
|
||||
className='text-muted-foreground bg-muted/30 px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase'
|
||||
>
|
||||
{label}
|
||||
</th>
|
||||
{ALL_MODALITIES.map((modality) => {
|
||||
const enabled = set.has(modality)
|
||||
const Icon = MODALITY_META[modality].icon
|
||||
return (
|
||||
<td
|
||||
key={modality}
|
||||
className={cn(
|
||||
return (
|
||||
<StaticDataTable
|
||||
className='rounded-lg'
|
||||
tableClassName='text-sm'
|
||||
headerRowClassName='bg-muted/40'
|
||||
data={[
|
||||
{ label: t('Input'), set: inputSet },
|
||||
{ label: t('Output'), set: outputSet },
|
||||
]}
|
||||
getRowKey={(row) => row.label}
|
||||
columns={[
|
||||
{
|
||||
id: 'modality',
|
||||
header: t('Modality'),
|
||||
className:
|
||||
'text-muted-foreground px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase',
|
||||
cellClassName:
|
||||
'text-muted-foreground bg-muted/30 px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase',
|
||||
cell: (row) => row.label,
|
||||
},
|
||||
...ALL_MODALITIES.map((modality) => ({
|
||||
id: modality,
|
||||
header: t(MODALITY_META[modality].labelKey),
|
||||
className:
|
||||
'text-muted-foreground border-l px-3 py-2 text-center text-[11px] font-medium tracking-wider uppercase',
|
||||
cellClassName: (row: { label: string; set: Set<Modality> }) =>
|
||||
cn(
|
||||
'border-l px-3 py-2 text-center',
|
||||
enabled
|
||||
row.set.has(modality)
|
||||
? 'bg-emerald-50/40 dark:bg-emerald-500/10'
|
||||
: 'bg-background'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center',
|
||||
enabled
|
||||
? 'text-emerald-700 dark:text-emerald-300'
|
||||
: 'text-muted-foreground/40'
|
||||
)}
|
||||
aria-label={
|
||||
enabled
|
||||
? t('{{modality}} supported', {
|
||||
modality: t(MODALITY_META[modality].labelKey),
|
||||
})
|
||||
: t('{{modality}} not supported', {
|
||||
modality: t(MODALITY_META[modality].labelKey),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Icon className='size-4' />
|
||||
</span>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='overflow-x-auto rounded-lg border'>
|
||||
<table className='w-full text-sm'>
|
||||
<thead>
|
||||
<tr className='bg-muted/40'>
|
||||
<th
|
||||
scope='col'
|
||||
className='text-muted-foreground px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase'
|
||||
>
|
||||
{t('Modality')}
|
||||
</th>
|
||||
{ALL_MODALITIES.map((modality) => (
|
||||
<th
|
||||
key={modality}
|
||||
scope='col'
|
||||
className='text-muted-foreground border-l px-3 py-2 text-center text-[11px] font-medium tracking-wider uppercase'
|
||||
),
|
||||
cell: (row: { label: string; set: Set<Modality> }) => {
|
||||
const enabled = row.set.has(modality)
|
||||
const Icon = MODALITY_META[modality].icon
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center',
|
||||
enabled
|
||||
? 'text-emerald-700 dark:text-emerald-300'
|
||||
: 'text-muted-foreground/40'
|
||||
)}
|
||||
aria-label={
|
||||
enabled
|
||||
? t('{{modality}} supported', {
|
||||
modality: t(MODALITY_META[modality].labelKey),
|
||||
})
|
||||
: t('{{modality}} not supported', {
|
||||
modality: t(MODALITY_META[modality].labelKey),
|
||||
})
|
||||
}
|
||||
>
|
||||
{t(MODALITY_META[modality].labelKey)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{renderRow(t('Input'), inputSet)}
|
||||
{renderRow(t('Output'), outputSet)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Icon className='size-4' />
|
||||
</span>
|
||||
)
|
||||
},
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+52
-57
@@ -22,13 +22,9 @@ import { AlertTriangle, HeartPulse, Timer } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
StaticDataTable,
|
||||
staticDataTableClassNames as tableStyles,
|
||||
} from '@/components/data-table'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { getPerfMetrics } from '@/features/performance-metrics/api'
|
||||
import {
|
||||
@@ -218,9 +214,6 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
|
||||
intent = 'default'
|
||||
}
|
||||
|
||||
const headerCellClass =
|
||||
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase'
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='grid grid-cols-1 gap-2 sm:grid-cols-3'>
|
||||
@@ -256,53 +249,55 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
|
||||
title={t('Per-group performance')}
|
||||
description={t('Average latency, TTFT, TPS, and success rate')}
|
||||
/>
|
||||
<div className='overflow-x-auto rounded-lg border'>
|
||||
<Table className='text-sm'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className={headerCellClass}>{t('Group')}</TableHead>
|
||||
<TableHead className={`${headerCellClass} text-right`}>
|
||||
TPS
|
||||
</TableHead>
|
||||
<TableHead className={`${headerCellClass} text-right`}>
|
||||
{t('Average TTFT')}
|
||||
</TableHead>
|
||||
<TableHead className={`${headerCellClass} text-right`}>
|
||||
{t('Average latency')}
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className={`${headerCellClass} min-w-[180px] text-left`}
|
||||
>
|
||||
{t('Success rate')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{performances.map((perf) => (
|
||||
<TableRow key={perf.group}>
|
||||
<TableCell className='py-2.5'>
|
||||
<GroupBadge group={perf.group} size='sm' />
|
||||
</TableCell>
|
||||
<TableCell className='py-2.5 text-right font-mono'>
|
||||
{formatThroughput(perf.avg_tps)}
|
||||
</TableCell>
|
||||
<TableCell className='py-2.5 text-right font-mono'>
|
||||
{formatLatency(perf.avg_ttft_ms)}
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground py-2.5 text-right font-mono'>
|
||||
{formatLatency(perf.avg_latency_ms)}
|
||||
</TableCell>
|
||||
<TableCell className='py-2.5'>
|
||||
<UptimeSparkline
|
||||
size='sm'
|
||||
series={uptimeByGroup[perf.group] ?? []}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<StaticDataTable
|
||||
className='rounded-lg'
|
||||
tableClassName='text-sm'
|
||||
headerRowClassName={tableStyles.compactHeaderRow}
|
||||
data={performances}
|
||||
getRowKey={(perf) => perf.group}
|
||||
columns={[
|
||||
{
|
||||
id: 'group',
|
||||
header: t('Group'),
|
||||
className: tableStyles.compactHeaderCell,
|
||||
cellClassName: tableStyles.compactCell,
|
||||
cell: (perf) => <GroupBadge group={perf.group} size='sm' />,
|
||||
},
|
||||
{
|
||||
id: 'tps',
|
||||
header: 'TPS',
|
||||
className: tableStyles.compactHeaderCellRight,
|
||||
cellClassName: tableStyles.compactNumericCell,
|
||||
cell: (perf) => formatThroughput(perf.avg_tps),
|
||||
},
|
||||
{
|
||||
id: 'ttft',
|
||||
header: t('Average TTFT'),
|
||||
className: tableStyles.compactHeaderCellRight,
|
||||
cellClassName: tableStyles.compactNumericCell,
|
||||
cell: (perf) => formatLatency(perf.avg_ttft_ms),
|
||||
},
|
||||
{
|
||||
id: 'latency',
|
||||
header: t('Average latency'),
|
||||
className: tableStyles.compactHeaderCellRight,
|
||||
cellClassName: tableStyles.compactMutedNumericCell,
|
||||
cell: (perf) => formatLatency(perf.avg_latency_ms),
|
||||
},
|
||||
{
|
||||
id: 'success',
|
||||
header: t('Success rate'),
|
||||
className: cn(tableStyles.compactHeaderCell, 'min-w-[180px]'),
|
||||
cellClassName: tableStyles.compactCell,
|
||||
cell: (perf) => (
|
||||
<UptimeSparkline
|
||||
size='sm'
|
||||
series={uptimeByGroup[perf.group] ?? []}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
|
||||
+167
-177
@@ -32,16 +32,9 @@ import {
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { sideDrawerContentClassName } from '@/components/drawer-layout'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { PublicLayout } from '@/components/layout'
|
||||
@@ -269,9 +262,7 @@ function ModelHeader(props: { model: PricingModel }) {
|
||||
const { t } = useTranslation()
|
||||
const model = props.model
|
||||
const modelIconKey = model.icon || model.vendor_icon
|
||||
const modelIcon = modelIconKey
|
||||
? getLobeIcon(modelIconKey, 20)
|
||||
: null
|
||||
const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 20) : null
|
||||
const description = model.description || model.vendor_description || null
|
||||
const tags = parseTags(model.tags)
|
||||
const isSpecialExpression =
|
||||
@@ -586,6 +577,40 @@ function AutoGroupChain(props: { model: PricingModel; autoGroups: string[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
type DynamicPriceOptions = Parameters<typeof getDynamicPriceEntries>[1]
|
||||
type DynamicPricingTier = ReturnType<typeof getDynamicPricingTiers>[number]
|
||||
type DynamicFormattedPricesByTier = Map<DynamicPricingTier, Map<string, string>>
|
||||
|
||||
function getDynamicPriceFields(
|
||||
tiers: DynamicPricingTier[],
|
||||
options: DynamicPriceOptions
|
||||
) {
|
||||
return Array.from(
|
||||
new Map(
|
||||
tiers
|
||||
.flatMap((tier) => getDynamicPriceEntries(tier, options))
|
||||
.map((entry) => [entry.field, entry])
|
||||
).values()
|
||||
)
|
||||
}
|
||||
|
||||
function getDynamicFormattedPricesByTier(
|
||||
tiers: DynamicPricingTier[],
|
||||
options: DynamicPriceOptions
|
||||
): DynamicFormattedPricesByTier {
|
||||
return new Map(
|
||||
tiers.map((tier) => [
|
||||
tier,
|
||||
new Map(
|
||||
getDynamicPriceEntries(tier, options).map((entry) => [
|
||||
entry.field,
|
||||
entry.formatted,
|
||||
])
|
||||
),
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Group pricing table
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -676,20 +701,27 @@ function GroupPricingSection(props: {
|
||||
)
|
||||
}
|
||||
|
||||
const priceFields = Array.from(
|
||||
new Map(
|
||||
dynamicTiers
|
||||
.flatMap((tier) =>
|
||||
getDynamicPriceEntries(tier, {
|
||||
tokenUnit: props.tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate: props.priceRate,
|
||||
usdExchangeRate: props.usdExchangeRate,
|
||||
groupRatioMultiplier: 1,
|
||||
})
|
||||
)
|
||||
.map((entry) => [entry.field, entry])
|
||||
).values()
|
||||
const priceFields = getDynamicPriceFields(dynamicTiers, {
|
||||
tokenUnit: props.tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate: props.priceRate,
|
||||
usdExchangeRate: props.usdExchangeRate,
|
||||
groupRatioMultiplier: 1,
|
||||
})
|
||||
const formattedPricesByGroup = new Map(
|
||||
availableGroups.map((group) => {
|
||||
const ratio = props.groupRatio[group] || 1
|
||||
return [
|
||||
group,
|
||||
getDynamicFormattedPricesByTier(dynamicTiers, {
|
||||
tokenUnit: props.tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate: props.priceRate,
|
||||
usdExchangeRate: props.usdExchangeRate,
|
||||
groupRatioMultiplier: ratio,
|
||||
}),
|
||||
] as const
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -699,6 +731,10 @@ function GroupPricingSection(props: {
|
||||
<div className='space-y-3'>
|
||||
{availableGroups.map((group) => {
|
||||
const ratio = props.groupRatio[group] || 1
|
||||
const formattedPricesByTier =
|
||||
formattedPricesByGroup.get(group) ??
|
||||
new Map<DynamicPricingTier, Map<string, string>>()
|
||||
|
||||
return (
|
||||
<div key={group} className='overflow-hidden rounded-lg border'>
|
||||
<div className='bg-muted/20 flex items-center justify-between gap-3 border-b px-3 py-2'>
|
||||
@@ -707,56 +743,34 @@ function GroupPricingSection(props: {
|
||||
{ratio}x
|
||||
</span>
|
||||
</div>
|
||||
<div className='overflow-x-auto'>
|
||||
<Table className='text-sm'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className={thClass}>{t('Tier')}</TableHead>
|
||||
{priceFields.map((entry) => (
|
||||
<TableHead
|
||||
key={entry.field}
|
||||
className={`${thClass} text-right`}
|
||||
>
|
||||
{t(entry.shortLabel)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dynamicTiers.map((tier, tierIndex) => {
|
||||
const entries = getDynamicPriceEntries(tier, {
|
||||
tokenUnit: props.tokenUnit,
|
||||
showRechargePrice,
|
||||
priceRate: props.priceRate,
|
||||
usdExchangeRate: props.usdExchangeRate,
|
||||
groupRatioMultiplier: ratio,
|
||||
})
|
||||
const entryMap = new Map(
|
||||
entries.map((entry) => [entry.field, entry])
|
||||
)
|
||||
|
||||
return (
|
||||
<TableRow key={`${group}-${tier.label || tierIndex}`}>
|
||||
<TableCell className='text-muted-foreground py-2.5'>
|
||||
{tier.label || t('Default')}
|
||||
</TableCell>
|
||||
{priceFields.map((fieldEntry) => {
|
||||
const entry = entryMap.get(fieldEntry.field)
|
||||
return (
|
||||
<TableCell
|
||||
key={fieldEntry.field}
|
||||
className='py-2.5 text-right font-mono'
|
||||
>
|
||||
{entry?.formatted ?? '-'}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<StaticDataTable
|
||||
className='rounded-none border-0'
|
||||
tableClassName='text-sm'
|
||||
headerRowClassName='hover:bg-transparent'
|
||||
data={dynamicTiers}
|
||||
getRowKey={(tier, tierIndex) =>
|
||||
`${group}-${tier.label || tierIndex}`
|
||||
}
|
||||
columns={[
|
||||
{
|
||||
id: 'tier',
|
||||
header: t('Tier'),
|
||||
className: thClass,
|
||||
cellClassName: 'text-muted-foreground py-2.5',
|
||||
cell: (tier) => tier.label || t('Default'),
|
||||
},
|
||||
...priceFields.map((fieldEntry) => ({
|
||||
id: fieldEntry.field,
|
||||
header: t(fieldEntry.shortLabel),
|
||||
className: `${thClass} text-right`,
|
||||
cellClassName: 'py-2.5 text-right font-mono',
|
||||
cell: (tier: (typeof dynamicTiers)[number]) =>
|
||||
formattedPricesByTier
|
||||
.get(tier)
|
||||
?.get(fieldEntry.field) ?? '-',
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -768,112 +782,88 @@ function GroupPricingSection(props: {
|
||||
)
|
||||
}
|
||||
|
||||
const renderGroupPrice = (group: string, type: PriceType) =>
|
||||
formatGroupPrice(
|
||||
props.model,
|
||||
group,
|
||||
type,
|
||||
props.tokenUnit,
|
||||
showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
)
|
||||
const renderFixedGroupPrice = (group: string) =>
|
||||
formatFixedPrice(
|
||||
props.model,
|
||||
group,
|
||||
showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
)
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle>{t('Pricing by Group')}</SectionTitle>
|
||||
<AutoGroupChain model={props.model} autoGroups={props.autoGroups} />
|
||||
<div className='-mx-4 overflow-x-auto sm:mx-0'>
|
||||
<Table className='text-sm'>
|
||||
<TableHeader>
|
||||
<TableRow className='hover:bg-transparent'>
|
||||
<TableHead className={thClass}>{t('Group')}</TableHead>
|
||||
<TableHead className={thClass}>{t('Ratio')}</TableHead>
|
||||
{isTokenBased ? (
|
||||
<>
|
||||
<TableHead className={`${thClass} text-right`}>
|
||||
{t('Input')}
|
||||
</TableHead>
|
||||
<TableHead className={`${thClass} text-right`}>
|
||||
{t('Output')}
|
||||
</TableHead>
|
||||
{extraPriceTypes.map((ep) => (
|
||||
<TableHead
|
||||
key={ep.type}
|
||||
className={`${thClass} text-right`}
|
||||
>
|
||||
{ep.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<TableHead className={`${thClass} text-right`}>
|
||||
{t('Price')}
|
||||
</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{availableGroups.map((group) => {
|
||||
const ratio = props.groupRatio[group] || 1
|
||||
return (
|
||||
<TableRow key={group}>
|
||||
<TableCell className='py-2.5'>
|
||||
<GroupBadge group={group} size='sm' />
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground py-2.5 font-mono'>
|
||||
{ratio}x
|
||||
</TableCell>
|
||||
{isTokenBased ? (
|
||||
<>
|
||||
<TableCell className='py-2.5 text-right font-mono'>
|
||||
{formatGroupPrice(
|
||||
props.model,
|
||||
group,
|
||||
'input',
|
||||
props.tokenUnit,
|
||||
showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className='py-2.5 text-right font-mono'>
|
||||
{formatGroupPrice(
|
||||
props.model,
|
||||
group,
|
||||
'output',
|
||||
props.tokenUnit,
|
||||
showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
)}
|
||||
</TableCell>
|
||||
{extraPriceTypes.map((ep) => (
|
||||
<TableCell
|
||||
key={ep.type}
|
||||
className='py-2.5 text-right font-mono'
|
||||
>
|
||||
{formatGroupPrice(
|
||||
props.model,
|
||||
group,
|
||||
ep.type,
|
||||
props.tokenUnit,
|
||||
showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<TableCell className='py-2.5 text-right font-mono'>
|
||||
{formatFixedPrice(
|
||||
props.model,
|
||||
group,
|
||||
showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
props.groupRatio
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<StaticDataTable
|
||||
className='-mx-4 rounded-none border-0 sm:mx-0'
|
||||
tableClassName='text-sm'
|
||||
headerRowClassName='hover:bg-transparent'
|
||||
data={availableGroups}
|
||||
getRowKey={(group) => group}
|
||||
columns={[
|
||||
{
|
||||
id: 'group',
|
||||
header: t('Group'),
|
||||
className: thClass,
|
||||
cellClassName: 'py-2.5',
|
||||
cell: (group) => <GroupBadge group={group} size='sm' />,
|
||||
},
|
||||
{
|
||||
id: 'ratio',
|
||||
header: t('Ratio'),
|
||||
className: thClass,
|
||||
cellClassName: 'text-muted-foreground py-2.5 font-mono',
|
||||
cell: (group) => `${props.groupRatio[group] || 1}x`,
|
||||
},
|
||||
...(isTokenBased
|
||||
? [
|
||||
{
|
||||
id: 'input',
|
||||
header: t('Input'),
|
||||
className: `${thClass} text-right`,
|
||||
cellClassName: 'py-2.5 text-right font-mono',
|
||||
cell: (group: string) => renderGroupPrice(group, 'input'),
|
||||
},
|
||||
{
|
||||
id: 'output',
|
||||
header: t('Output'),
|
||||
className: `${thClass} text-right`,
|
||||
cellClassName: 'py-2.5 text-right font-mono',
|
||||
cell: (group: string) => renderGroupPrice(group, 'output'),
|
||||
},
|
||||
...extraPriceTypes.map((ep) => ({
|
||||
id: ep.type,
|
||||
header: ep.label,
|
||||
className: `${thClass} text-right`,
|
||||
cellClassName: 'py-2.5 text-right font-mono',
|
||||
cell: (group: string) => renderGroupPrice(group, ep.type),
|
||||
})),
|
||||
]
|
||||
: [
|
||||
{
|
||||
id: 'price',
|
||||
header: t('Price'),
|
||||
className: `${thClass} text-right`,
|
||||
cellClassName: 'py-2.5 text-right font-mono',
|
||||
cell: renderFixedGroupPrice,
|
||||
},
|
||||
]),
|
||||
]}
|
||||
/>
|
||||
<div className='-mx-4 sm:mx-0'>
|
||||
{isTokenBased && (
|
||||
<p className='text-muted-foreground/40 mt-1.5 px-4 text-[10px] sm:px-0'>
|
||||
{t('Prices shown per')} {tokenUnitLabel} tokens
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user