Compare commits

...
17 Commits
Author SHA1 Message Date
QuentinHsuandGitHub 6f415428d3 perf(web): improve frontend table rendering and pinned columns/UI table (#5405)
* refactor(web): centralize data table implementation

- route all TanStack table setup through a shared data-table hook to remove repeated state and row model wiring.
- move table rendering, static table wrappers, empty states, and primitive exports behind the data-table module.
- update feature tables and configuration editors to share the same table UX while preserving their existing workflows.

* refactor(web): trim data table public API

- remove unused data-table exports and dead static table helper types.
- keep internal table header, skeleton, empty state, and faceted filter helpers private to the data-table module.
- route feature imports through the data-table barrel to avoid subpath coupling.

* refactor(web): unify table rendering components

- centralize static table headers, bodies, empty states, and shared class names behind the data-table package.
- migrate settings, pricing, channel, key, subscription, and model tables to the shared table APIs.
- remove data-table exports for low-level table primitives so feature code uses one supported abstraction.

* perf(web): keep list tables fixed within page content

- make shared data table pages fill available height and scroll row data inside the table body.
- add a fixed content layout mode so selected list pages avoid page-level scrolling.
- apply the fixed table behavior to keys, logs, channels, models, users, redemptions, and subscriptions.

* perf(web): refine table pagination controls

- show total row counts instead of redundant page range text.
- tighten visible page buttons so pagination fits constrained table widths.
- align pagination controls and tune text hierarchy for clearer scanning.

* perf(web): stabilize model pricing table columns

- keep model pricing columns at fixed widths so headers do not collapse in narrow layouts.
- truncate long model names and pricing summaries within their cells instead of squeezing adjacent columns.

* refactor(web): simplify data table rendering internals

- split table body rendering into focused helpers for loading, empty, and row states.
- extract static table row and cell class resolution to reduce branching in the main component.
- reuse a single pagination page-size option list to avoid duplicated constants.

* perf(pricing): reduce dynamic pricing table render work

- reuse dynamic pricing field metadata instead of rebuilding it inside table columns.
- precompute formatted dynamic prices per tier and group to avoid repeated entry mapping for each cell.
- simplify select option construction in related dialogs while preserving the same choices.

* refactor(web): streamline pricing table rendering

- reuse translated endpoint select options between trigger data and menu items.
- precompute dynamic pricing maps per group so table cells only resolve formatted values.
- add local dynamic pricing type aliases to keep helper signatures readable.

* refactor(web): merge pricing table imports

* refactor(web): merge upstream ratio table imports

* refactor(web): merge channel selector table imports

* refactor(web): simplify tiered pricing select items

* refactor(web): reuse model ratio row state

* refactor(web): rely on table view row defaults

* refactor(web): reuse pagination state values

* refactor(web): hoist pagination size select items

* refactor(web): clarify static table body rows

* refactor(web): extract table page pagination rendering

* fix(web): remove direct hast type dependency

- rely on Shiki transformer contextual typing for line nodes.
- allow frontend typecheck to pass without an undeclared hast package.

* refactor(web): trim data table hook return API

- return only the TanStack table instance from useDataTable.
- keep internal state handling private because callers do not consume it directly.

* refactor(web): keep static table empty row private

- stop exporting the internal StaticDataTableEmptyRow helper.
- keep the public static table API focused on the table component and column type.

* refactor(web): hide data table view props from barrel

* refactor(web): remove stale long text lint override

* fix(web): keep pinned table columns opaque

- apply pinned column background classes after custom column classes.
- use an opaque hover background so scrolled content cannot show through fixed cells.

* refactor(data-table): organize shared table components

- group table primitives, page composition, toolbar controls, static tables, and hooks by responsibility.
- split shared view types, row rendering, header rendering, and pinned-column styling out of the main table view.
- keep the public data-table barrel stable while documenting the new ownership boundaries.

* fix(web): stabilize split table column sizing

- derive default colgroup widths from visible columns when split headers or header sizing are enabled.
- apply a fixed table layout with computed minimum width so header and body columns stay aligned.
- keep split-header containers from leaking horizontal overflow and avoid extra pinned-column borders.

* fix(web): set stable table utility column widths

- assign fixed widths to selection columns so shared colgroup sizing keeps checkbox cells compact.
- size id columns in redemption and user tables to keep split headers aligned with body rows.

* fix(web): align model metadata icon cells

- render compact provider avatars in the metadata icon column instead of wide wordmarks.
- position icons in a fixed-size wrapper so they line up with the existing icon header alignment.

* fix(status-badge): hide status dot by default

* fix(web): prevent user invite info overlap

- give the invite info and created-at columns explicit widths so table sizing reserves enough space.
- allow invite badges to wrap within the cell instead of spilling into adjacent columns.

* perf(data-table): cache pinned column class resolution

- reuse the pinned column lookup while table props stay stable to reduce repeated per-render work.
- share the resolved column class handler across unified and split-header table layouts.
- localize page-number screen reader labels so pagination remains accessible in every locale.

* refactor(data-table): tighten static table modes

- make StaticDataTable distinguish data-driven and children-only usage through explicit prop shapes.
- remove unsupported columns-without-data fallback after confirming no repository callers rely on it.
- default manual table modes away from unused local row models to reduce repeated table work.

* fix(data-table): make pinned edit column opaque

- use an opaque muted background for the active action column so sticky cells do not reveal scrolled content underneath.

* fix(data-table): prevent narrow column overlap

- apply stable header sizing to remaining desktop data table pages so constrained layouts scroll instead of compressing cells.
- add explicit widths for key, quota, badge, and timestamp columns that contain fixed-format content.
- constrain masked values and timestamp cells with truncation to keep content inside its assigned column.

* fix(table): align table cell content with headers

- remove extra inline padding from masked table text buttons so values start at the cell edge.
- tag status badges and offset leading badges inside table cells to match header text alignment.

* fix(table): prevent admin list column overflow

- widen redemption and subscription table columns so masked codes, timestamps, and localized headers fit.
- localize subscription ID headers and add Received amount translations across supported locales.

* fix(provider-badge): unify provider icon spacing

- add a shared provider badge component for icon and status label layout.
- reuse it in channel type and model vendor columns so OpenAI icons align consistently.
2026-06-11 02:36:41 +08:00
CaIon 59a93cf5c7 fix(openai): align image streaming relay governance
Route OpenAI image streaming through shared stream handling, split image/realtime/usage helpers for maintainability, and include the related image request and rate limit updates.
2026-06-10 17:47:37 +08:00
Benson YanandGitHub 867d8acfc3 fix: normalize kimi k2.6 temperature (#5390) 2026-06-10 17:19:57 +08:00
Q.A.zhandGitHub 30d3a3a5f7 perf(web): add debounce channel search and skip during IME composition (#5393) 2026-06-10 17:18:51 +08:00
d2576ddcd3 fix(openai): support streaming image relay and image edit for images API (#4608)
* fix(openai): support streaming image relay

* fix(openai): keep image edit multipart body reusable

* test(openai): cover image stream usage details

* test(openai): cover image edit fallback stream field

* fix(openai): wrap image json fallback as stream

* fix(relay): support OpenAI image streaming

* fix(openai): record image stream upstream error events

* fix(openai): harden image stream relay

* fix(openai): return image JSON errors

* fix(relay): reset stream status per scanner run

* fix(relay): drop upstream credit passthrough

* fix(openai): keep image errors minimal

* fix(openai): keep image error status from response

---------

Co-authored-by: CaIon <i@caion.me>
2026-06-08 18:36:17 +08:00
同語andGitHub 4ca47ee236 fix: support six-decimal steps in model pricing editor (#5332)
Merge pull request #5332 from yyhhyyyyyy/fix/model-pricing-six-decimal-step
2026-06-06 23:22:37 +08:00
同語andGitHub 16dd7237c0 fix: align mobile usage log cost badge (#5161)
Merge pull request #5161 from yyhhyyyyyy/fix/mobile-usage-log-cost-alignment
2026-06-06 23:19:07 +08:00
同語andGitHub 1915344838 fix: respect theme for multiselect combobox popover (#5328)
Merge pull request #5328 from yyhhyyyyyy/fix/multiselect-popover-theme
2026-06-06 23:18:04 +08:00
同語andGitHub 15ff8e0268 chore(web): improve frontend dialog layout and sizing (#5346)
Merge pull request #5346 from QuantumNous/perf/ui-dialog
2026-06-06 23:16:53 +08:00
同語andGitHub a1c82841b5 chore(web): simplify public page hero copy (#5339)
Merge pull request #5339 from QuantumNous/perf/compact-display
2026-06-06 23:15:05 +08:00
QuentinHsu 2eaa943d9f perf(web): improve dialog sizing and footer layout
- migrate frontend dialogs to the shared footer API so actions stay separated from scrollable body content.
- tune dialog dimensions for model analytics, prefill groups, billing history, channel model sync, and related workflows.
- update channel terminology and dialog action translations across supported locales.
2026-06-06 21:49:33 +08:00
QuentinHsu 7a5348caa3 feat(web): add shared dialog wrapper
- introduce a reusable dialog component for consistent header, body, and footer layout.
- support per-dialog sizing, trigger rendering, initial focus, and close button controls.
- preserve base dialog open and close motion classes while allowing content-specific styling.
2026-06-06 18:47:10 +08:00
QuentinHsu f5753a2b31 perf(web): simplify public page hero copy 2026-06-06 15:49:38 +08:00
yyhhyyyyyy e8c36762fd fix: support six-decimal steps in model pricing editor 2026-06-05 17:24:33 +08:00
yyhhyyyyyy e2dbd02cbb Merge remote-tracking branch 'upstream/main' into fix/mobile-usage-log-cost-alignment
# Conflicts:
#	web/default/src/features/usage-logs/components/usage-logs-mobile-card.tsx
2026-06-05 14:11:55 +08:00
yyhhyyyyyy c8d3768087 fix: respect theme for multiselect combobox popover 2026-06-05 14:02:26 +08:00
yyhhyyyyyy 979aeceb5c fix: align mobile usage log cost badge 2026-05-28 19:17:47 +08:00
178 changed files with 13159 additions and 11936 deletions
+2 -2
View File
@@ -112,11 +112,11 @@ func InitEnv() {
// Initialize rate limit variables // Initialize rate limit variables
GlobalApiRateLimitEnable = GetEnvOrDefaultBool("GLOBAL_API_RATE_LIMIT_ENABLE", true) 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)) GlobalApiRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_API_RATE_LIMIT_DURATION", 180))
GlobalWebRateLimitEnable = GetEnvOrDefaultBool("GLOBAL_WEB_RATE_LIMIT_ENABLE", true) 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)) GlobalWebRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT_DURATION", 180))
CriticalRateLimitEnable = GetEnvOrDefaultBool("CRITICAL_RATE_LIMIT_ENABLE", true) CriticalRateLimitEnable = GetEnvOrDefaultBool("CRITICAL_RATE_LIMIT_ENABLE", true)
+6 -6
View File
@@ -26,11 +26,11 @@ type ImageRequest struct {
OutputFormat json.RawMessage `json:"output_format,omitempty"` OutputFormat json.RawMessage `json:"output_format,omitempty"`
OutputCompression json.RawMessage `json:"output_compression,omitempty"` OutputCompression json.RawMessage `json:"output_compression,omitempty"`
PartialImages json.RawMessage `json:"partial_images,omitempty"` PartialImages json.RawMessage `json:"partial_images,omitempty"`
// Stream bool `json:"stream,omitempty"` Stream *bool `json:"stream,omitempty"`
Images json.RawMessage `json:"images,omitempty"` Images json.RawMessage `json:"images,omitempty"`
Mask json.RawMessage `json:"mask,omitempty"` Mask json.RawMessage `json:"mask,omitempty"`
InputFidelity json.RawMessage `json:"input_fidelity,omitempty"` InputFidelity json.RawMessage `json:"input_fidelity,omitempty"`
Watermark *bool `json:"watermark,omitempty"` Watermark *bool `json:"watermark,omitempty"`
// zhipu 4v // zhipu 4v
WatermarkEnabled json.RawMessage `json:"watermark_enabled,omitempty"` WatermarkEnabled json.RawMessage `json:"watermark_enabled,omitempty"`
UserId json.RawMessage `json:"user_id,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 { func (i *ImageRequest) IsStream(c *gin.Context) bool {
return false return i.Stream != nil && *i.Stream
} }
func (i *ImageRequest) SetModelName(modelName string) { func (i *ImageRequest) SetModelName(modelName string) {
+16
View File
@@ -5,7 +5,9 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strings"
"github.com/QuantumNous/new-api/common"
channelconstant "github.com/QuantumNous/new-api/constant" channelconstant "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/relay/channel" "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) { 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 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) { func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
// TODO implement me // TODO implement me
return nil, errors.New("not implemented") return nil, errors.New("not implemented")
+68
View File
@@ -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)
}
+12 -4
View File
@@ -9,6 +9,7 @@ import (
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/textproto" "net/textproto"
"net/url"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -439,10 +440,13 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
// 使用已解析的 multipart 表单,避免重复解析 // 使用已解析的 multipart 表单,避免重复解析
mf := c.Request.MultipartForm mf := c.Request.MultipartForm
if mf == nil { if mf == nil {
if _, err := c.MultipartForm(); err != nil { form, err := common.ParseMultipartFormReusable(c)
return nil, errors.New("failed to parse multipart form") 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: case relayconstant.RelayModeAudioTranscription:
err, usage = OpenaiSTTHandler(c, resp, info, a.ResponseFormat) err, usage = OpenaiSTTHandler(c, resp, info, a.ResponseFormat)
case relayconstant.RelayModeImagesGenerations, relayconstant.RelayModeImagesEdits: 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: case relayconstant.RelayModeRerank:
usage, err = common_handler.RerankHandler(c, info, resp) usage, err = common_handler.RerankHandler(c, info, resp)
case relayconstant.RelayModeResponses: case relayconstant.RelayModeResponses:
+98
View File
@@ -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)
})
}
+173
View File
@@ -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`)
}
-421
View File
@@ -14,12 +14,9 @@ import (
relaycommon "github.com/QuantumNous/new-api/relay/common" relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper" "github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/types" "github.com/QuantumNous/new-api/types"
"github.com/bytedance/gopkg/util/gopool"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
) )
func sendStreamData(c *gin.Context, info *relaycommon.RelayInfo, data string, forceFormat bool, thinkToContent bool) error { 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 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
}
+287
View File
@@ -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)
}
+242
View File
@@ -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
}
+133
View File
@@ -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
}
+1 -1
View File
@@ -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) { func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
switch info.RelayMode { switch info.RelayMode {
case constant.RelayModeImagesGenerations, constant.RelayModeImagesEdits: case constant.RelayModeImagesGenerations, constant.RelayModeImagesEdits:
usage, err = openai.OpenaiHandlerWithUsage(c, info, resp) usage, err = openai.OpenaiImageHandler(c, info, resp)
case constant.RelayModeResponses: case constant.RelayModeResponses:
if info.IsStream { if info.IsStream {
usage, err = openai.OaiResponsesStreamHandler(c, info, resp) usage, err = openai.OaiResponsesStreamHandler(c, info, resp)
+71
View File
@@ -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")
})
}
+2 -2
View File
@@ -22,8 +22,8 @@ import (
) )
const ( const (
InitialScannerBufferSize = 64 << 10 // 64KB (64*1024) InitialScannerBufferSize = 64 << 10 // 64KB (64*1024)
DefaultMaxScannerBufferSize = 64 << 20 // 64MB (64*1024*1024) default SSE buffer size DefaultMaxScannerBufferSize = 128 << 20 // 64MB (64*1024*1024) default SSE buffer size
DefaultPingInterval = 10 * time.Second DefaultPingInterval = 10 * time.Second
) )
+2 -2
View File
@@ -631,7 +631,7 @@ func TestStreamScannerHandler_StreamStatus_InitializedIfNil(t *testing.T) {
assert.NotNil(t, info.StreamStatus) assert.NotNil(t, info.StreamStatus)
} }
func TestStreamScannerHandler_StreamStatus_PreInitialized(t *testing.T) { func TestStreamScannerHandler_StreamStatus_ReplacesPreInitialized(t *testing.T) {
t.Parallel() t.Parallel()
body := buildSSEBody(5) body := buildSSEBody(5)
@@ -643,7 +643,7 @@ func TestStreamScannerHandler_StreamStatus_PreInitialized(t *testing.T) {
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {}) StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {})
assert.Equal(t, relaycommon.StreamEndReasonDone, info.StreamStatus.EndReason) 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) { func TestStreamScannerHandler_PingInterleavesWithSlowUpstream(t *testing.T) {
+13 -2
View File
@@ -4,6 +4,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"math" "math"
"net/url"
"strconv"
"strings" "strings"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
@@ -144,16 +146,25 @@ func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageReq
switch relayMode { switch relayMode {
case relayconstant.RelayModeImagesEdits: case relayconstant.RelayModeImagesEdits:
if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") { if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
_, err := c.MultipartForm() form, err := common.ParseMultipartFormReusable(c)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse image edit form request: %w", err) 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.Prompt = formData.Get("prompt")
imageRequest.Model = formData.Get("model") imageRequest.Model = formData.Get("model")
imageRequest.N = common.GetPointer(uint(common.String2Int(formData.Get("n")))) imageRequest.N = common.GetPointer(uint(common.String2Int(formData.Get("n"))))
imageRequest.Quality = formData.Get("quality") imageRequest.Quality = formData.Get("quality")
imageRequest.Size = formData.Get("size") 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 != "" { if imageValue := formData.Get("image"); imageValue != "" {
imageRequest.Image, _ = common.Marshal(imageValue) imageRequest.Image, _ = common.Marshal(imageValue)
} }
+1 -2
View File
@@ -27,7 +27,6 @@ import {
useEffect, useEffect,
useState, useState,
} from 'react' } from 'react'
import type { Element } from 'hast'
import { CheckIcon, CopyIcon } from 'lucide-react' import { CheckIcon, CopyIcon } from 'lucide-react'
import { import {
type BundledLanguage, type BundledLanguage,
@@ -53,7 +52,7 @@ const CodeBlockContext = createContext<CodeBlockContextType>({
const lineNumberTransformer: ShikiTransformer = { const lineNumberTransformer: ShikiTransformer = {
name: 'line-numbers', name: 'line-numbers',
line(node: Element, line: number) { line(node, line) {
node.children.unshift({ node.children.unshift({
type: 'element', type: 'element',
tagName: 'span', tagName: 'span',
+17
View File
@@ -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}
/>
)
}
@@ -39,48 +39,55 @@ type DataTablePaginationProps<TData> = {
table: Table<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>({ export function DataTablePagination<TData>({
table, table,
}: DataTablePaginationProps<TData>) { }: DataTablePaginationProps<TData>) {
const { t } = useTranslation() 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 totalPages = table.getPageCount()
const totalRows = table.getRowCount()
const pageNumbers = getPageNumbers(currentPage, totalPages) const pageNumbers = getPageNumbers(currentPage, totalPages)
return ( return (
<div <div
className={cn( className={cn(
'flex items-center justify-between overflow-clip', '@container/pagination flex min-w-0 items-center justify-end overflow-clip'
'@max-2xl/content:flex-col-reverse @max-2xl/content:gap-2 sm:@max-2xl/content:gap-4'
)} )}
style={{ overflowClipMargin: 1 }} style={{ overflowClipMargin: 1 }}
> >
<div className='flex w-full items-center justify-between gap-2'> <div className='flex min-w-0 shrink-0 items-center gap-2 @xl/pagination:gap-3'>
<div className='flex min-w-0 items-center text-xs font-medium whitespace-nowrap sm:min-w-[130px] sm:text-sm @2xl/content:hidden'> <div className='flex shrink-0 items-baseline gap-1.5 text-xs font-medium whitespace-nowrap sm:text-sm'>
{t('Page {{current}} of {{total}}', { <span className='text-muted-foreground/80'>{t('Total:')}</span>
current: currentPage, <span className='text-foreground tabular-nums'>
total: totalPages, {totalRows.toLocaleString()}
})} </span>
</div> </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 <Select
items={[ items={PAGE_SIZE_SELECT_ITEMS}
...[10, 20, 30, 40, 50, 100].map((pageSize) => ({ value={`${pageSize}`}
value: `${pageSize}`,
label: pageSize,
})),
]}
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => { onValueChange={(value) => {
table.setPageSize(Number(value)) table.setPageSize(Number(value))
}} }}
> >
<SelectTrigger className='h-8 w-[64px] sm:w-[70px]'> <SelectTrigger className='text-foreground h-8 w-[64px] font-medium tabular-nums sm:w-[70px]'>
<SelectValue placeholder={table.getState().pagination.pageSize} /> <SelectValue placeholder={pageSize} />
</SelectTrigger> </SelectTrigger>
<SelectContent side='top' alignItemWithTrigger={false}> <SelectContent side='top' alignItemWithTrigger={false}>
<SelectGroup> <SelectGroup>
{[10, 20, 30, 40, 50, 100].map((pageSize) => ( {PAGE_SIZE_OPTIONS.map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}> <SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize} {pageSize}
</SelectItem> </SelectItem>
@@ -88,23 +95,12 @@ export function DataTablePagination<TData>({
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
<p className='hidden text-sm font-medium sm:block'>
{t('Rows per page')}
</p>
</div> </div>
</div>
<div className='flex items-center sm:space-x-6 lg:space-x-8'> <div className='flex min-w-0 shrink-0 items-center gap-1 @lg/pagination:gap-1.5 @xl/pagination:gap-2'>
<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'>
<Button <Button
variant='outline' 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)} onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()} disabled={!table.getCanPreviousPage()}
> >
@@ -113,7 +109,7 @@ export function DataTablePagination<TData>({
</Button> </Button>
<Button <Button
variant='outline' 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()} onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()} disabled={!table.getCanPreviousPage()}
> >
@@ -121,18 +117,26 @@ export function DataTablePagination<TData>({
<ChevronLeftIcon className='h-4 w-4' /> <ChevronLeftIcon className='h-4 w-4' />
</Button> </Button>
{/* Page number buttons */}
{pageNumbers.map((pageNumber, index) => ( {pageNumbers.map((pageNumber, index) => (
<div key={`${pageNumber}-${index}`} className='flex items-center'> <div key={`${pageNumber}-${index}`} className='flex items-center'>
{pageNumber === '...' ? ( {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 <Button
variant={currentPage === pageNumber ? 'default' : 'outline'} 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)} 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} {pageNumber}
</Button> </Button>
)} )}
@@ -141,7 +145,7 @@ export function DataTablePagination<TData>({
<Button <Button
variant='outline' 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()} onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()} disabled={!table.getCanNextPage()}
> >
@@ -150,7 +154,7 @@ export function DataTablePagination<TData>({
</Button> </Button>
<Button <Button
variant='outline' 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)} onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()} 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%' }
}
+71
View File
@@ -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
View File
@@ -16,16 +16,30 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
export { DataTablePagination } from './pagination' export { DataTablePagination } from './core/pagination'
export { DataTableColumnHeader } from './column-header' export { DataTableColumnHeader } from './core/column-header'
export { DataTableFacetedFilter } from './faceted-filter' export { DataTableViewOptions } from './toolbar/view-options'
export { DataTableViewOptions } from './view-options' export { DataTableToolbar } from './toolbar/toolbar'
export { DataTableToolbar } from './toolbar' export { DataTableBulkActions } from './toolbar/bulk-actions'
export { DataTableBulkActions } from './bulk-actions' export {
export { TableSkeleton } from './table-skeleton' StaticDataTable,
export { TableEmpty } from './table-empty' type StaticDataTableColumn,
export { MobileCardList } from './mobile-card-list' } from './static/static-data-table'
export { DataTablePage, type DataTablePageProps } from './data-table-page' 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 = 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' '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'
@@ -18,27 +18,22 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import * as React from 'react' import * as React from 'react'
import { import {
flexRender,
type ColumnDef, type ColumnDef,
type Row, type Row,
type Table as TanstackTable, type Table as TanstackTable,
} from '@tanstack/react-table' } from '@tanstack/react-table'
import { useMediaQuery } from '@/hooks' import { useMediaQuery } from '@/hooks'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { PageFooterPortal } from '@/components/layout' 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 { 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}. * 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. * Custom desktop row renderer replaces the default `<TableRow>`/`<TableCell>` mapping.
* Use for expanded rows, aggregate rows, click-on-row navigation, etc. * 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>`. * Apply explicit column widths from `header.getSize()` to `<TableHead>`.
@@ -182,6 +192,12 @@ export type DataTablePageProps<TData> = {
*/ */
className?: string 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). * Desktop table container className (the bordered scroll wrapper).
*/ */
@@ -189,7 +205,8 @@ export type DataTablePageProps<TData> = {
/** /**
* Desktop `<TableHeader>` className override. * 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 tableHeaderClassName?: string
} }
@@ -222,10 +239,18 @@ export function DataTablePage<TData>(props: DataTablePageProps<TData>) {
const toolbarNode = renderToolbar(props) const toolbarNode = renderToolbar(props)
const mobileNode = renderMobile(props, showMobile) const mobileNode = renderMobile(props, showMobile)
const desktopNode = renderDesktop(props, showMobile) const desktopNode = renderDesktop(props, showMobile)
const paginationNode = renderPagination(props)
return ( 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} {toolbarNode}
{mobileNode} {mobileNode}
{desktopNode} {desktopNode}
@@ -236,16 +261,7 @@ export function DataTablePage<TData>(props: DataTablePageProps<TData>) {
handle its own visibility, we just gate it to non-mobile. */} handle its own visibility, we just gate it to non-mobile. */}
{!showMobile && props.bulkActions} {!showMobile && props.bulkActions}
{props.showPagination !== false && {paginationNode}
(props.paginationInFooter !== false ? (
<PageFooterPortal>
<DataTablePagination table={props.table} />
</PageFooterPortal>
) : (
<div className='pt-2'>
<DataTablePagination table={props.table} />
</div>
))}
</> </>
) )
} }
@@ -265,12 +281,25 @@ function renderToolbar<TData>(
return null 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>( function renderMobile<TData>(
props: DataTablePageProps<TData>, props: DataTablePageProps<TData>,
showMobile: boolean showMobile: boolean
): React.ReactNode { ): React.ReactNode {
if (!showMobile) return null if (!showMobile) return null
if (props.mobile !== undefined) return props.mobile
const ownGetRowClassName = props.getRowClassName const ownGetRowClassName = props.getRowClassName
const mobileGetRowClassName = const mobileGetRowClassName =
@@ -278,8 +307,7 @@ function renderMobile<TData>(
(ownGetRowClassName (ownGetRowClassName
? (row: Row<TData>) => ownGetRowClassName(row, { isMobile: true }) ? (row: Row<TData>) => ownGetRowClassName(row, { isMobile: true })
: undefined) : undefined)
const mobileContent = props.mobile ?? (
return (
<MobileCardList <MobileCardList
table={props.table} table={props.table}
isLoading={props.isLoading} isLoading={props.isLoading}
@@ -289,6 +317,8 @@ function renderMobile<TData>(
getRowClassName={mobileGetRowClassName} getRowClassName={mobileGetRowClassName}
/> />
) )
return <div className='min-h-0 flex-1 overflow-y-auto'>{mobileContent}</div>
} }
function renderDesktop<TData>( function renderDesktop<TData>(
@@ -297,94 +327,37 @@ function renderDesktop<TData>(
): React.ReactNode { ): React.ReactNode {
if (showMobile) return null if (showMobile) return null
const rows = props.table.getRowModel().rows
const isFetchingOnly = props.isFetching && !props.isLoading const isFetchingOnly = props.isFetching && !props.isLoading
const fixedHeight = props.fixedHeight !== false
return ( return (
<div <DataTableView
className={cn( table={props.table}
'overflow-hidden rounded-lg border transition-opacity duration-150', 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', isFetchingOnly && 'pointer-events-none opacity-60',
props.tableClassName props.tableClassName
)} )}
> getRowClassName={(row) =>
<Table> props.getRowClassName?.(row, { isMobile: false })
<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>
) )
} }
@@ -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>
)
}
@@ -21,6 +21,7 @@ import { useState, type ReactNode } from 'react'
import { type Table } from '@tanstack/react-table' import { type Table } from '@tanstack/react-table'
import { ChevronDown, Loader2, X as Cross2Icon } from 'lucide-react' import { ChevronDown, Loader2, X as Cross2Icon } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useDebounce } from '@/hooks'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -46,6 +47,10 @@ export type DataTableToolbarProps<TData> = {
* Placeholder for the default search input. Defaults to `t('Filter...')`. * Placeholder for the default search input. Defaults to `t('Filter...')`.
*/ */
searchPlaceholder?: string 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 * Column id to filter on. When provided, the search input filters
* a specific column. When omitted, the search input updates the * 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>) { export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
const { t } = useTranslation() const { t } = useTranslation()
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const isSearchComposingRef = React.useRef(false)
const lastCommittedSearchValueRef = React.useRef('')
const filters = props.filters ?? [] const filters = props.filters ?? []
const hasExpandable = props.expandable != null const hasExpandable = props.expandable != null
@@ -147,26 +154,109 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
!!props.hasAdditionalFilters !!props.hasAdditionalFilters
const placeholder = props.searchPlaceholder ?? t('Filter...') 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 ? ( const searchInput = props.searchKey ? (
<Input <Input
placeholder={placeholder} placeholder={placeholder}
value={ value={searchValue}
(props.table.getColumn(props.searchKey)?.getFilterValue() as string) ?? onChange={handleSearchChange}
'' onCompositionStart={handleSearchCompositionStart}
} onCompositionEnd={handleSearchCompositionEnd}
onChange={(event) =>
props.table
.getColumn(props.searchKey!)
?.setFilterValue(event.target.value)
}
className='w-full sm:w-[200px] lg:w-[240px]' className='w-full sm:w-[200px] lg:w-[240px]'
/> />
) : ( ) : (
<Input <Input
placeholder={placeholder} placeholder={placeholder}
value={props.table.getState().globalFilter ?? ''} value={searchValue}
onChange={(event) => props.table.setGlobalFilter(event.target.value)} onChange={handleSearchChange}
onCompositionStart={handleSearchCompositionStart}
onCompositionEnd={handleSearchCompositionEnd}
className='w-full sm:w-[200px] lg:w-[240px]' className='w-full sm:w-[200px] lg:w-[240px]'
/> />
) )
@@ -186,6 +276,10 @@ export function DataTableToolbar<TData>(props: DataTableToolbarProps<TData>) {
}) })
const handleReset = () => { const handleReset = () => {
isSearchComposingRef.current = false
setSearchValue('')
setPendingSearchValue('')
lastCommittedSearchValueRef.current = ''
props.table.resetColumnFilters() props.table.resetColumnFilters()
props.table.setGlobalFilter('') props.table.setGlobalFilter('')
props.onReset?.() props.onReset?.()
+127
View File
@@ -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>
)
}
@@ -25,15 +25,8 @@ import { useNotifications } from '@/hooks/use-notifications'
import { useSystemConfig } from '@/hooks/use-system-config' import { useSystemConfig } from '@/hooks/use-system-config'
import { useTopNavLinks } from '@/hooks/use-top-nav-links' import { useTopNavLinks } from '@/hooks/use-top-nav-links'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Dialog } from '@/components/dialog'
import { LanguageSwitcher } from '@/components/language-switcher' import { LanguageSwitcher } from '@/components/language-switcher'
import { NotificationPopover } from '@/components/notification-popover' import { NotificationPopover } from '@/components/notification-popover'
import { ProfileDropdown } from '@/components/profile-dropdown' import { ProfileDropdown } from '@/components/profile-dropdown'
@@ -427,28 +420,26 @@ export function PublicHeader(props: PublicHeaderProps) {
closeAuthPrompt() closeAuthPrompt()
} }
}} }}
> title={t('Sign in required')}
<DialogContent className='sm:max-w-md'> description={t('Please sign in to view {{module}}.', {
<DialogHeader> module: authPromptTarget?.title || '',
<DialogTitle>{t('Sign in required')}</DialogTitle> })}
<DialogDescription> contentClassName='sm:max-w-md'
{t('Please sign in to view {{module}}.', { contentHeight='auto'
module: authPromptTarget?.title || '', footer={
})} <>
</DialogDescription>
</DialogHeader>
<div className='bg-muted/40 text-muted-foreground rounded-lg px-3 py-2 text-sm'>
{t('Redirecting to sign in in {{seconds}} seconds.', {
seconds: authPromptSecondsLeft,
})}
</div>
<DialogFooter>
<Button variant='outline' onClick={closeAuthPrompt}> <Button variant='outline' onClick={closeAuthPrompt}>
{t('Cancel')} {t('Cancel')}
</Button> </Button>
<Button onClick={navigateToSignIn}>{t('Sign in now')}</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> </Dialog>
</> </>
) )
@@ -50,6 +50,7 @@ SectionPageLayoutBreadcrumb.displayName = 'SectionPageLayout.Breadcrumb'
export type SectionPageLayoutProps = { export type SectionPageLayoutProps = {
children: ReactNode children: ReactNode
fixedContent?: boolean
} }
export function SectionPageLayout(props: SectionPageLayoutProps) { export function SectionPageLayout(props: SectionPageLayoutProps) {
@@ -95,7 +96,13 @@ export function SectionPageLayout(props: SectionPageLayoutProps) {
</div> </div>
</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} {content}
</div> </div>
-1
View File
@@ -46,7 +46,6 @@ export function LongText({
useEffect(() => { useEffect(() => {
if (checkOverflow(ref.current)) { if (checkOverflow(ref.current)) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsOverflown(true) setIsOverflown(true)
return return
} }
+7 -3
View File
@@ -42,14 +42,18 @@ interface MaskedValueDisplayProps {
*/ */
export function MaskedValueDisplay(props: MaskedValueDisplayProps) { export function MaskedValueDisplay(props: MaskedValueDisplayProps) {
return ( return (
<div className='flex items-center'> <div className='flex max-w-full min-w-0 items-center'>
<Popover> <Popover>
<PopoverTrigger <PopoverTrigger
render={ 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> </PopoverTrigger>
<PopoverContent <PopoverContent
className='w-auto max-w-[min(90vw,28rem)]' className='w-auto max-w-[min(90vw,28rem)]'
+44
View File
@@ -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
View File
@@ -103,7 +103,7 @@ export function StatusBadge({
variant, variant,
size = 'sm', size = 'sm',
pulse = false, pulse = false,
showDot = true, showDot = false,
copyable = true, copyable = true,
copyText, copyText,
autoColor, autoColor,
@@ -130,6 +130,7 @@ export function StatusBadge({
return ( return (
<span <span
data-slot='status-badge'
className={cn( className={cn(
'inline-flex w-fit max-w-full shrink-0 items-center rounded-4xl font-medium tracking-normal whitespace-nowrap transition-colors', 'inline-flex w-fit max-w-full shrink-0 items-center rounded-4xl font-medium tracking-normal whitespace-nowrap transition-colors',
sizeMap[size ?? 'sm'], sizeMap[size ?? 'sm'],
+1 -1
View File
@@ -180,7 +180,7 @@ function ComboboxContent({
data-slot='combobox-content' data-slot='combobox-content'
data-chips={!!anchor} data-chips={!!anchor}
className={cn( 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 className
)} )}
{...props} {...props}
+1 -1
View File
@@ -103,7 +103,7 @@ function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
<td <td
data-slot='table-cell' data-slot='table-cell'
className={cn( 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 className
)} )}
{...props} {...props}
@@ -20,16 +20,9 @@ import { useMemo } from 'react'
import { ShieldCheck, KeyRound, Loader2 } from 'lucide-react' import { ShieldCheck, KeyRound, Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Dialog } from '@/components/dialog'
import type { import type {
SecureVerificationState, SecureVerificationState,
VerificationMethod, VerificationMethod,
@@ -91,122 +84,118 @@ export function SecureVerificationDialog({
(activeMethod === '2fa' && (!state.code.trim() || state.code.length < 6)) (activeMethod === '2fa' && (!state.code.trim() || state.code.length < 6))
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog
<DialogContent open={open}
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' onOpenChange={onOpenChange}
showCloseButton={!state.loading} title={
> <>
<div className='bg-background flex max-h-[calc(100dvh-2rem)] flex-col'> <ShieldCheck className='text-primary h-5 w-5' />
<DialogHeader className='border-b px-6 py-5 text-left'> {title}
<DialogTitle className='flex items-center gap-2 text-lg font-semibold'> </>
<ShieldCheck className='text-primary h-5 w-5' /> }
{title} description={description}
</DialogTitle> 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'
<DialogDescription className='text-left'> headerClassName='border-b pb-4 text-left'
{description} titleClassName='flex items-center gap-2 text-lg font-semibold'
</DialogDescription> descriptionClassName='text-left'
</DialogHeader> 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'> <TabsContent value='2fa' className='space-y-3'>
{availableTabs.length === 0 ? ( <p className='text-muted-foreground text-sm'>
<div className='grid place-items-center gap-4 text-center'> {t(
<div className='bg-muted flex h-16 w-16 items-center justify-center rounded-2xl'> 'Enter the 6-digit Time-based One-Time Password or 8-character backup code from your authenticator app.'
<ShieldCheck className='text-muted-foreground h-8 w-8' /> )}
</div> </p>
<p className='text-muted-foreground text-sm'> <Input
{t( inputMode='numeric'
'Enable Two-factor Authentication or Passkey in your profile to unlock sensitive operations.' maxLength={8}
)} value={state.code}
</p> onChange={(event) => onCodeChange(event.target.value)}
</div> placeholder={t('Enter verification code')}
) : ( disabled={state.loading}
<Tabs autoFocus={activeMethod === '2fa'}
value={activeMethod ?? availableTabs[0]} onKeyDown={(event) => {
onValueChange={(value) => if (event.key === 'Enter' && !verifyDisabled) {
onMethodChange(value as VerificationMethod) event.preventDefault()
handleVerify()
} }
className='gap-4' }}
> />
<TabsList> </TabsContent>
{methods.has2FA && (
<TabsTrigger value='2fa'>
{t('Authenticator code')}
</TabsTrigger>
)}
{methods.hasPasskey && methods.passkeySupported && (
<TabsTrigger value='passkey'>{t('Passkey')}</TabsTrigger>
)}
</TabsList>
<TabsContent value='2fa' className='space-y-3'> <TabsContent value='passkey' className='space-y-4'>
<p className='text-muted-foreground text-sm'> <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( {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> </p>
<Input </div>
inputMode='numeric' </div>
maxLength={8} </div>
value={state.code} {!methods.passkeySupported && (
onChange={(event) => onCodeChange(event.target.value)} <p className='text-destructive text-sm'>
placeholder={t('Enter verification code')} {t('This device does not support Passkey verification.')}
disabled={state.loading} </p>
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> </TabsContent>
</Tabs>
<DialogFooter className='bg-muted/30 border-t px-6 py-4 sm:flex-row sm:justify-end'> )}
<Button
type='button'
variant='outline'
disabled={state.loading}
onClick={onCancel}
>
{t('Cancel')}
</Button>
<Button
type='button'
onClick={handleVerify}
disabled={availableTabs.length === 0 || verifyDisabled}
>
{state.loading && <Loader2 className='h-4 w-4 animate-spin' />}
{t('Verify')}
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog> </Dialog>
) )
} }
@@ -32,14 +32,6 @@ import {
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useStatus } from '@/hooks/use-status' import { useStatus } from '@/hooks/use-status'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { import {
Form, Form,
FormControl, FormControl,
@@ -50,6 +42,7 @@ import {
} from '@/components/ui/form' } from '@/components/ui/form'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { PasswordInput } from '@/components/password-input' import { PasswordInput } from '@/components/password-input'
import { Turnstile } from '@/components/turnstile' import { Turnstile } from '@/components/turnstile'
import { login, wechatLoginByCode } from '@/features/auth/api' import { login, wechatLoginByCode } from '@/features/auth/api'
@@ -414,43 +407,16 @@ export function UserAuthForm({
<Dialog <Dialog
open={isWeChatDialogOpen} open={isWeChatDialogOpen}
onOpenChange={handleWeChatDialogChange} onOpenChange={handleWeChatDialogChange}
> title={t('WeChat sign in')}
<DialogContent className='max-w-sm'> description={t(
<DialogHeader className='text-left'> 'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
<DialogTitle>{t('WeChat sign in')}</DialogTitle> )}
<DialogDescription> contentClassName='max-w-sm'
{t( headerClassName='text-left'
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.' contentHeight='auto'
)} bodyClassName='space-y-4'
</DialogDescription> footer={
</DialogHeader> <>
{wechatQrCodeUrl ? (
<div className='flex justify-center'>
<img
src={wechatQrCodeUrl}
alt={t('WeChat login QR code')}
className='h-40 w-40 rounded-md border object-contain'
/>
</div>
) : (
<p className='text-muted-foreground text-sm'>
{t('QR code is not configured. Please contact support.')}
</p>
)}
<div className='grid gap-2'>
<Label htmlFor='wechat-code'>{t('Verification code')}</Label>
<Input
id='wechat-code'
placeholder={t('Enter the verification code')}
value={wechatCode}
onChange={(event) => setWeChatCode(event.target.value)}
autoComplete='one-time-code'
/>
</div>
<DialogFooter>
<Button <Button
type='button' type='button'
variant='outline' variant='outline'
@@ -474,8 +440,32 @@ export function UserAuthForm({
) : null} ) : null}
{t('Confirm')} {t('Confirm')}
</Button> </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> </Dialog>
)} )}
</Form> </Form>
@@ -26,14 +26,6 @@ import { toast } from 'sonner'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useStatus } from '@/hooks/use-status' import { useStatus } from '@/hooks/use-status'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { import {
Form, Form,
FormControl, FormControl,
@@ -44,6 +36,7 @@ import {
} from '@/components/ui/form' } from '@/components/ui/form'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { PasswordInput } from '@/components/password-input' import { PasswordInput } from '@/components/password-input'
import { Turnstile } from '@/components/turnstile' import { Turnstile } from '@/components/turnstile'
import { register, wechatLoginByCode } from '@/features/auth/api' import { register, wechatLoginByCode } from '@/features/auth/api'
@@ -387,43 +380,16 @@ export function SignUpForm({
<Dialog <Dialog
open={isWeChatDialogOpen} open={isWeChatDialogOpen}
onOpenChange={handleWeChatDialogChange} onOpenChange={handleWeChatDialogChange}
> title={t('WeChat sign in')}
<DialogContent className='max-w-sm'> description={t(
<DialogHeader className='text-left'> 'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
<DialogTitle>{t('WeChat sign in')}</DialogTitle> )}
<DialogDescription> contentClassName='max-w-sm'
{t( headerClassName='text-left'
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.' contentHeight='auto'
)} bodyClassName='space-y-4'
</DialogDescription> footer={
</DialogHeader> <>
{wechatQrCodeUrl ? (
<div className='flex justify-center'>
<img
src={wechatQrCodeUrl}
alt={t('WeChat login QR code')}
className='h-40 w-40 rounded-md border object-contain'
/>
</div>
) : (
<p className='text-muted-foreground text-sm'>
{t('QR code is not configured. Please contact support.')}
</p>
)}
<div className='grid gap-2'>
<Label htmlFor='wechat-code'>{t('Verification code')}</Label>
<Input
id='wechat-code'
placeholder={t('Enter the verification code')}
value={wechatCode}
onChange={(event) => setWeChatCode(event.target.value)}
autoComplete='one-time-code'
/>
</div>
<DialogFooter>
<Button <Button
type='button' type='button'
variant='outline' variant='outline'
@@ -447,8 +413,32 @@ export function SignUpForm({
) : null} ) : null}
{t('Confirm')} {t('Confirm')}
</Button> </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> </Dialog>
)} )}
</Form> </Form>
@@ -35,7 +35,6 @@ import {
formatTimestampToDate, formatTimestampToDate,
formatQuota as formatQuotaValue, formatQuota as formatQuotaValue,
} from '@/lib/format' } from '@/lib/format'
import { getLobeIcon } from '@/lib/lobe-icon'
import { truncateText } from '@/lib/utils' import { truncateText } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
@@ -46,8 +45,9 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { ConfirmDialog } from '@/components/confirm-dialog' 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 { GroupBadge } from '@/components/group-badge'
import { ProviderBadge } from '@/components/provider-badge'
import { StatusBadge, StatusBadgeList } from '@/components/status-badge' import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
import { TableId } from '@/components/table-id' import { TableId } from '@/components/table-id'
import { TruncatedText } from '@/components/truncated-text' import { TruncatedText } from '@/components/truncated-text'
@@ -623,7 +623,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
const typeNameKey = getChannelTypeLabel(type) const typeNameKey = getChannelTypeLabel(type)
const typeName = t(typeNameKey) const typeName = t(typeNameKey)
const iconName = getChannelTypeIcon(type) const iconName = getChannelTypeIcon(type)
const icon = getLobeIcon(`${iconName}.Color`, 14)
const channel = row.original as Channel const channel = row.original as Channel
const isMultiKey = isMultiKeyChannel(channel) const isMultiKey = isMultiKeyChannel(channel)
const multiKeyMode = channel.channel_info?.multi_key_mode ?? 'random' const multiKeyMode = channel.channel_info?.multi_key_mode ?? 'random'
@@ -657,16 +656,12 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
)} )}
<StatusBadge <ProviderBadge
autoColor={typeName} iconKey={iconName}
size='sm' label={typeName}
copyable={false} copyable={false}
showDot={false} showDot={false}
className='gap-1 pl-1' />
>
{icon}
<span className='truncate'>{typeName}</span>
</StatusBadge>
{isIonet && ( {isIonet && (
<TooltipProvider delay={100}> <TooltipProvider delay={100}>
<Tooltip> <Tooltip>
@@ -16,20 +16,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com 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 { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router' import { getRouteApi } from '@tanstack/react-router'
import { import {
getCoreRowModel,
useReactTable,
getExpandedRowModel,
type OnChangeFn, type OnChangeFn,
type SortingState, type SortingState,
type VisibilityState,
type ExpandedState,
type Row, type Row,
} from '@tanstack/react-table' } from '@tanstack/react-table'
import { useDebounce, useMediaQuery } from '@/hooks' import { useMediaQuery } from '@/hooks'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { getLobeIcon } from '@/lib/lobe-icon' import { getLobeIcon } from '@/lib/lobe-icon'
import { useTableUrlState } from '@/hooks/use-table-url-state' import { useTableUrlState } from '@/hooks/use-table-url-state'
@@ -38,6 +33,8 @@ import {
DISABLED_ROW_DESKTOP, DISABLED_ROW_DESKTOP,
DISABLED_ROW_MOBILE, DISABLED_ROW_MOBILE,
DataTablePage, DataTablePage,
useDebouncedColumnFilter,
useDataTable,
} from '@/components/data-table' } from '@/components/data-table'
import { getChannels, searchChannels, getGroups } from '../api' import { getChannels, searchChannels, getGroups } from '../api'
import { import {
@@ -81,12 +78,6 @@ export function ChannelsTable() {
// Table state // Table state
const [sorting, setSorting] = useState<SortingState>([]) 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 // URL state management
const { const {
@@ -116,35 +107,24 @@ export function ChannelsTable() {
// Extract filters from column filters // Extract filters from column filters
const statusFilter = const statusFilter =
(columnFilters.find((f) => f.id === 'status')?.value as string[]) || [] (columnFilters.find((f) => f.id === 'status')?.value as string[]) || []
const typeFilter = const typeFilter = useMemo(
(columnFilters.find((f) => f.id === 'type')?.value as string[]) || [] () => (columnFilters.find((f) => f.id === 'type')?.value as string[]) || [],
[columnFilters]
)
const groupFilter = const groupFilter =
(columnFilters.find((f) => f.id === 'group')?.value as string[]) || [] (columnFilters.find((f) => f.id === 'group')?.value as string[]) || []
const modelFilterFromUrl = const {
(columnFilters.find((f) => f.id === 'model')?.value as string) || '' value: modelFilter,
inputValue: modelFilterInput,
// Local state for immediate input feedback onChange: onModelFilterInputChange,
const [modelFilterInput, setModelFilterInput] = useState(modelFilterFromUrl) onCompositionStart: onModelFilterCompositionStart,
const debouncedModelFilter = useDebounce(modelFilterInput, 500) onCompositionEnd: onModelFilterCompositionEnd,
resetInput: resetModelFilterInput,
// Sync local input with URL when URL changes (e.g., from back/forward navigation) } = useDebouncedColumnFilter({
useEffect(() => { columnFilters,
setModelFilterInput(modelFilterFromUrl) columnId: 'model',
}, [modelFilterFromUrl]) onColumnFiltersChange,
})
// 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
// Determine whether to use search or regular list API // Determine whether to use search or regular list API
const shouldSearch = Boolean(globalFilter?.trim() || modelFilter.trim()) const shouldSearch = Boolean(globalFilter?.trim() || modelFilter.trim())
@@ -279,41 +259,31 @@ export function ChannelsTable() {
const columns = useChannelsColumns() const columns = useChannelsColumns()
// React Table instance // React Table instance
const table = useReactTable({ const { table } = useDataTable({
data: channels, data: channels,
columns, columns,
pageCount: Math.ceil(totalCount / pagination.pageSize), totalCount,
state: { sorting,
sorting, initialColumnVisibility: {
columnFilters, models: false,
columnVisibility, tag: false,
rowSelection,
pagination,
expanded,
globalFilter,
}, },
columnFilters,
pagination,
globalFilter,
enableRowSelection: (row: Row<Channel>) => !isTagAggregateRow(row.original), enableRowSelection: (row: Row<Channel>) => !isTagAggregateRow(row.original),
onRowSelectionChange: setRowSelection,
onSortingChange: handleSortingChange, onSortingChange: handleSortingChange,
onColumnFiltersChange, onColumnFiltersChange,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange, onPaginationChange,
onExpandedChange: setExpanded,
onGlobalFilterChange, onGlobalFilterChange,
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getSubRows: (row: Channel & { children?: Channel[] }) => row.children, getSubRows: (row: Channel & { children?: Channel[] }) => row.children,
manualPagination: true, manualPagination: true,
manualSorting: true, manualSorting: true,
manualFiltering: 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. // Prepare filter options from existing channel types only.
const typeFilterOptions = useMemo(() => { const typeFilterOptions = useMemo(() => {
const counts = typeCounts || {} const counts = typeCounts || {}
@@ -385,11 +355,17 @@ export function ChannelsTable() {
applyHeaderSize applyHeaderSize
toolbarProps={{ toolbarProps={{
searchPlaceholder: t('Filter by name, ID, or key...'), searchPlaceholder: t('Filter by name, ID, or key...'),
searchDebounceMs: 500,
onReset: () => {
resetModelFilterInput()
},
additionalSearch: ( additionalSearch: (
<Input <Input
placeholder={t('Filter by model...')} placeholder={t('Filter by model...')}
value={modelFilterInput} value={modelFilterInput}
onChange={(e) => setModelFilterInput(e.target.value)} onChange={onModelFilterInputChange}
onCompositionStart={onModelFilterCompositionStart}
onCompositionEnd={onModelFilterCompositionEnd}
className='w-full sm:w-[150px] lg:w-[180px]' 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 { Power, PowerOff, Tag, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { import {
@@ -38,6 +30,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table' import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import { import {
handleBatchDelete, handleBatchDelete,
handleBatchDisable, handleBatchDisable,
@@ -188,29 +181,21 @@ export function DataTableBulkActions<TData>({
</BulkActionsToolbar> </BulkActionsToolbar>
{/* Set Tag Dialog */} {/* Set Tag Dialog */}
<Dialog open={showTagDialog} onOpenChange={setShowTagDialog}> <Dialog
<DialogContent> open={showTagDialog}
<DialogHeader> onOpenChange={setShowTagDialog}
<DialogTitle>{t('Set Tag')}</DialogTitle> title={t('Set Tag')}
<DialogDescription> description={
{t('Set a tag for')} {selectedIds.length}{' '} <>
{t('selected channel(s). Leave empty to remove tag.')} {t('Set a tag for')}
</DialogDescription> {selectedIds.length}{' '}
</DialogHeader> {t('selected channel(s). Leave empty to remove tag.')}
</>
<div className='grid gap-4 py-4'> }
<div className='grid gap-2'> contentHeight='auto'
<Label htmlFor='tag'>{t('Tag')}</Label> bodyClassName='space-y-4'
<Input footer={
id='tag' <>
placeholder={t('Enter tag name (optional)')}
value={tagValue}
onChange={(e) => setTagValue(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button <Button
variant='outline' variant='outline'
onClick={() => { onClick={() => {
@@ -221,22 +206,37 @@ export function DataTableBulkActions<TData>({
{t('Cancel')} {t('Cancel')}
</Button> </Button>
<Button onClick={handleSetTag}>{t('Set Tag')}</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> </Dialog>
{/* Delete Confirmation Dialog */} {/* Delete Confirmation Dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}> <Dialog
<DialogContent> open={showDeleteConfirm}
<DialogHeader> onOpenChange={setShowDeleteConfirm}
<DialogTitle>{t('Delete Channels?')}</DialogTitle> title={t('Delete Channels?')}
<DialogDescription> description={
{t('Are you sure you want to delete')} {selectedIds.length}{' '} <>
{t('channel(s)? This action cannot be undone.')} {t('Are you sure you want to delete')}
</DialogDescription> {selectedIds.length}{' '}
</DialogHeader> {t('channel(s)? This action cannot be undone.')}
</>
<DialogFooter> }
contentHeight='auto'
footer={
<>
<Button <Button
variant='outline' variant='outline'
onClick={() => setShowDeleteConfirm(false)} onClick={() => setShowDeleteConfirm(false)}
@@ -246,8 +246,10 @@ export function DataTableBulkActions<TData>({
<Button variant='destructive' onClick={handleDeleteAll}> <Button variant='destructive' onClick={handleDeleteAll}>
{t('Delete')} {t('Delete')}
</Button> </Button>
</DialogFooter> </>
</DialogContent> }
>
{' '}
</Dialog> </Dialog>
</> </>
) )
@@ -24,14 +24,7 @@ import { toast } from 'sonner'
import { formatCurrencyFromUSD } from '@/lib/currency' import { formatCurrencyFromUSD } from '@/lib/currency'
import { formatTimestampToDate } from '@/lib/format' import { formatTimestampToDate } from '@/lib/format'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import { Dialog } from '@/components/dialog'
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { getCodexUsage, updateChannelBalance } from '../../api' import { getCodexUsage, updateChannelBalance } from '../../api'
import { channelsQueryKeys } from '../../lib' import { channelsQueryKeys } from '../../lib'
import { useChannels } from '../channels-provider' import { useChannels } from '../channels-provider'
@@ -161,53 +154,55 @@ export function BalanceQueryDialog({
} }
return ( return (
<Dialog open={open} onOpenChange={handleClose}> <Dialog
<DialogContent> open={open}
<DialogHeader> onOpenChange={handleClose}
<DialogTitle>{t('Query Balance')}</DialogTitle> title={t('Query Balance')}
<DialogDescription> description={
{t('Update balance for:')} <strong>{currentRow.name}</strong> <>
</DialogDescription> {t('Update balance for:')}
</DialogHeader> <strong>{currentRow.name}</strong>
</>
<div className='space-y-4 py-4'> }
{/* Current Balance Display */} contentHeight='auto'
<div className='bg-muted/50 rounded-lg border p-4'> bodyClassName='space-y-4'
<div className='text-muted-foreground mb-2 flex items-center gap-2 text-sm'> footer={
<DollarSign className='h-4 w-4' /> <>
<span>{t('Current Balance')}</span>
</div>
<div className='text-2xl font-bold'>
{balance !== null
? formatBalance(balance)
: formatBalance(currentRow.balance)}
</div>
<div className='text-muted-foreground mt-2 text-xs'>
{t('Last updated:')}{' '}
{formatDate(
balanceUpdatedTime ?? currentRow.balance_updated_time
)}
</div>
</div>
{/* Balance Update Button */}
<Button
className='w-full'
onClick={handleQueryBalance}
disabled={isQuerying}
>
{isQuerying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{!isQuerying && <RefreshCw className='mr-2 h-4 w-4' />}
{isQuerying ? t('Querying...') : t('Update Balance')}
</Button>
</div>
<DialogFooter>
<Button variant='outline' onClick={handleClose} disabled={isQuerying}> <Button variant='outline' onClick={handleClose} disabled={isQuerying}>
{t('Close')} {t('Close')}
</Button> </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> </Dialog>
) )
} }
@@ -21,10 +21,6 @@ import {
type ColumnDef, type ColumnDef,
type RowSelectionState, type RowSelectionState,
type Table as TanStackTable, type Table as TanStackTable,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table' } from '@tanstack/react-table'
import { Check, Copy, Info, Loader2, Settings } from 'lucide-react' import { Check, Copy, Info, Loader2, Settings } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -33,14 +29,6 @@ import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { useIsMobile } from '@/hooks/use-mobile' import { useIsMobile } from '@/hooks/use-mobile'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { import {
@@ -60,21 +48,18 @@ import {
SheetTitle, SheetTitle,
} from '@/components/ui/sheet' } from '@/components/ui/sheet'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table' import {
import { DataTablePagination } from '@/components/data-table/pagination' DataTableBulkActions as BulkActionsToolbar,
DataTablePagination,
DataTableView,
useDataTable,
} from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import { import {
sideDrawerContentClassName, sideDrawerContentClassName,
sideDrawerFooterClassName, sideDrawerFooterClassName,
@@ -207,7 +192,7 @@ function getTestTableColumnClass(columnId: string) {
case 'status': case 'status':
return 'w-70 min-w-70 max-w-70 whitespace-normal' return 'w-70 min-w-70 max-w-70 whitespace-normal'
case 'actions': 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: default:
return undefined return undefined
} }
@@ -234,6 +219,14 @@ export function ChannelTestDialog({
pageIndex: 0, pageIndex: 0,
pageSize: 10, pageSize: 10,
}) })
const endpointSelectItems = useMemo(
() =>
endpointTypeOptions.map((option) => ({
value: option.value,
label: t(option.label),
})),
[t]
)
const resetState = useCallback(() => { const resetState = useCallback(() => {
setEndpointType('auto') setEndpointType('auto')
@@ -509,18 +502,17 @@ export function ChannelTestDialog({
] ]
) )
const table = useReactTable({ const { table } = useDataTable({
data: tableData, data: tableData,
columns, columns,
state: { rowSelection,
rowSelection, pagination,
pagination,
},
enableRowSelection: true, enableRowSelection: true,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination, onPaginationChange: setPagination,
withFilteredRowModel: false,
withSortedRowModel: false,
withFacetedRowModel: false,
}) })
if (!currentRow) { if (!currentRow) {
@@ -529,179 +521,137 @@ export function ChannelTestDialog({
return ( return (
<> <>
<Dialog open={open} onOpenChange={handleClose}> <Dialog
<DialogContent className='max-h-[90vh] overflow-hidden sm:max-w-3xl'> open={open}
<DialogHeader> onOpenChange={handleClose}
<DialogTitle>{t('Test Channel Connection')}</DialogTitle> title={t('Test Channel Connection')}
<DialogDescription> description={
{t('Test connectivity for:')} <strong>{currentRow.name}</strong> <>
</DialogDescription> {t('Test connectivity for:')}
</DialogHeader> <strong>{currentRow.name}</strong>
</>
<div className='max-h-[78vh] space-y-4 overflow-y-auto py-4 pr-1'> }
<div className='grid gap-4 md:grid-cols-2'> contentClassName='max-h-[90vh] overflow-hidden sm:max-w-3xl'
<div className='grid gap-2'> contentHeight='auto'
<Label htmlFor='endpoint-type'>{t('Endpoint Type')}</Label> bodyClassName='space-y-4'
<Select footer={
items={[ <>
...endpointTypeOptions.map((option) => {
const itemValue = option.value
return { value: itemValue, label: t(option.label) }
}),
]}
value={endpointType}
onValueChange={(v) => v !== null && setEndpointType(v)}
>
<SelectTrigger id='endpoint-type'>
<SelectValue placeholder={t('Auto detect (default)')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{endpointTypeOptions.map((option) => {
const itemValue = option.value
return (
<SelectItem key={itemValue} value={itemValue}>
{t(option.label)}
</SelectItem>
)
})}
</SelectGroup>
</SelectContent>
</Select>
<p className='text-muted-foreground text-xs'>
{t(
'Override the endpoint used for testing. Leave empty to auto detect.'
)}
</p>
</div>
<div className='grid gap-2'>
<Label htmlFor='stream-toggle'>{t('Stream Mode')}</Label>
<div className='flex items-center gap-2'>
<Switch
id='stream-toggle'
checked={isStreamTest}
onCheckedChange={setIsStreamTest}
disabled={streamDisabled}
/>
<span className='text-sm'>
{isStreamTest ? t('Enabled') : t('Disabled')}
</span>
</div>
<p className='text-muted-foreground text-xs'>
{t('Enable streaming mode for the test request.')}
</p>
</div>
</div>
<div className='space-y-3 max-sm:has-[div[role="toolbar"]]:pb-16'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='text-sm font-medium'>{t('Channel models')}</p>
<p className='text-muted-foreground text-xs'>
{t('Select models to run batch tests.')}
</p>
</div>
<Input
placeholder={t('Filter models...')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='sm:w-64'
/>
</div>
<div className='space-y-3'>
<div
className='overflow-hidden rounded-md border'
role='region'
aria-label={t('Channel models')}
>
<div className='max-h-90 overflow-auto **:data-[slot=table-container]:overflow-visible'>
<Table className='w-max min-w-full table-auto'>
<colgroup>
<col className='w-10 min-w-10' />
<col className='w-auto' />
<col className='w-70' />
<col className='w-24 sm:w-28' />
</colgroup>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className={getTestTableColumnClass(
header.column.id
)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() ? 'selected' : undefined
}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={getTestTableColumnClass(
cell.column.id
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={table.getVisibleLeafColumns().length}
className='text-muted-foreground h-16 text-center text-sm'
>
{models.length
? 'No models matched your search.'
: 'This channel has no configured models.'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<DataTablePagination table={table} />
</div>
<TestModelsBulkActions
table={table}
disabled={isAnyTesting}
onTestSelected={handleBatchTest}
/>
</div>
</div>
<DialogFooter>
<Button variant='outline' onClick={handleClose}> <Button variant='outline' onClick={handleClose}>
{t('Close')} {t('Close')}
</Button> </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> </Dialog>
<FailureDetailsSheet <FailureDetailsSheet
details={failureDetails} details={failureDetails}
@@ -24,15 +24,8 @@ import { tryPrettyJson } from '@/lib/utils'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog'
import { completeCodexOAuth, startCodexOAuth } from '../../api' import { completeCodexOAuth, startCodexOAuth } from '../../api'
type CodexOAuthDialogProps = { type CodexOAuthDialogProps = {
@@ -129,78 +122,18 @@ export function CodexOAuthDialog({
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog
<DialogContent className='sm:max-w-2xl'> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle>{t('Codex Authorization')}</DialogTitle> title={t('Codex Authorization')}
<DialogDescription> description={t(
{t( 'Generate a Codex OAuth credential and paste it into the channel key field.'
'Generate a Codex OAuth credential and paste it into the channel key field.' )}
)} contentClassName='sm:max-w-2xl'
</DialogDescription> contentHeight='auto'
</DialogHeader> bodyClassName='space-y-4'
footer={
<div className='space-y-4'> <>
<Alert>
<AlertDescription>
{t(
'1) Click "Open authorization page" and complete login. 2) Your browser may redirect to localhost (it is OK if the page does not load). 3) Copy the full URL from the address bar and paste it below. 4) Click "Generate credential".'
)}
</AlertDescription>
</Alert>
<div className='flex flex-wrap gap-2'>
<Button onClick={handleStart} disabled={state.isStarting}>
{state.isStarting ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<ExternalLink className='mr-2 h-4 w-4' />
)}
{t('Open authorization page')}
</Button>
<Button
type='button'
variant='outline'
disabled={!canCopyAuthorizeUrl}
onClick={async () => {
if (!state.authorizeUrl) return
await copyToClipboard(state.authorizeUrl)
}}
aria-label={t('Copy authorization link')}
title={t('Copy authorization link')}
>
{copiedText === state.authorizeUrl ? (
<Check className='mr-2 h-4 w-4 text-green-600' />
) : (
<Copy className='mr-2 h-4 w-4' />
)}
{t('Copy authorization link')}
</Button>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>{t('Callback URL')}</div>
<Input
value={state.callbackUrl}
onChange={(e) =>
setState((prev) => ({ ...prev, callbackUrl: e.target.value }))
}
placeholder={t(
'Paste the full callback URL (includes code & state)'
)}
autoComplete='off'
spellCheck={false}
/>
<div className='text-muted-foreground text-xs'>
{t(
'Tip: The generated key is a JSON credential including access_token / refresh_token / account_id.'
)}
</div>
</div>
</div>
<DialogFooter>
<Button <Button
type='button' type='button'
variant='outline' variant='outline'
@@ -215,8 +148,68 @@ export function CodexOAuthDialog({
)} )}
{state.isCompleting ? t('Generating...') : t('Generate credential')} {state.isCompleting ? t('Generating...') : t('Generate credential')}
</Button> </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> </Dialog>
) )
} }
@@ -31,16 +31,9 @@ import { useTranslation } from 'react-i18next'
import dayjs from '@/lib/dayjs' import dayjs from '@/lib/dayjs'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Dialog } from '@/components/dialog'
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge' import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
type CodexRateLimitWindow = { type CodexRateLimitWindow = {
@@ -414,177 +407,23 @@ export function CodexUsageDialog({
}, [response]) }, [response])
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog
<DialogContent className='sm:max-w-3xl'> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle className='flex items-center gap-2'> title={t('Codex Account & Usage')}
{t('Codex Account & Usage')} description={
</DialogTitle> <>
<DialogDescription> {t('Channel:')}
{t('Channel:')} <strong>{channelName || '-'}</strong>{' '} <strong>{channelName || '-'}</strong>{' '}
{channelId ? `(#${channelId})` : ''} {channelId ? `(#${channelId})` : ''}
</DialogDescription> </>
</DialogHeader> }
contentClassName='sm:max-w-3xl'
<div className='space-y-4'> titleClassName='flex items-center gap-2'
{errorMessage && ( contentHeight='auto'
<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'> bodyClassName='space-y-4'
{errorMessage} footer={
</div> <>
)}
{/* Account summary */}
<div className='rounded-lg border p-4'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='flex flex-wrap items-center gap-2'>
<StatusBadge
label={accountBadge.label}
variant={accountBadge.variant}
copyable={false}
/>
{statusBadge}
{typeof response?.upstream_status === 'number' && (
<StatusBadge
label={`${t('Status:')} ${response.upstream_status}`}
variant='neutral'
copyable={false}
/>
)}
</div>
{onRefresh && (
<Button
type='button'
variant='outline'
size='sm'
onClick={onRefresh}
disabled={Boolean(isRefreshing)}
>
<RefreshCw className='mr-1.5 h-3.5 w-3.5' />
{t('Refresh')}
</Button>
)}
</div>
{/* Account identity info */}
<div className='bg-muted/30 mt-3 rounded-md px-3 py-2'>
<CopyableField
icon={<User className='h-3.5 w-3.5' />}
label='User ID'
value={payload?.user_id}
mono
/>
<CopyableField
icon={<Mail className='h-3.5 w-3.5' />}
label={t('Email')}
value={payload?.email}
/>
<CopyableField
icon={<Hash className='h-3.5 w-3.5' />}
label='Account ID'
value={payload?.account_id}
mono
/>
</div>
</div>
{/* Rate limit windows */}
<div className='space-y-5'>
<div>
<div className='mb-1 text-sm font-medium'>
{t('Rate Limit Windows')}
</div>
<p className='text-muted-foreground mb-3 text-xs'>
{t(
'Tracks current account base limits and additional metered usage on Codex upstream.'
)}
</p>
<RateLimitGroupSection
title={t('Base Limits')}
description={t('Base rate limit windows for this account.')}
source={payload}
/>
</div>
{additionalRateLimits.length > 0 && (
<div className='space-y-4 border-t pt-4'>
<div>
<div className='text-sm font-medium'>
{t('Additional Limits')}
</div>
<p className='text-muted-foreground text-xs'>
{t(
'Per-feature metered windows split by model or capability.'
)}
</p>
</div>
<div className='space-y-4'>
{additionalRateLimits.map((item, index) => {
const limitName =
item.limit_name ||
item.metered_feature ||
`${t('Additional Limit')} ${index + 1}`
return (
<div
key={`${limitName}-${item.metered_feature ?? ''}-${index}`}
className={index > 0 ? 'border-t pt-4' : ''}
>
<RateLimitGroupSection
title={limitName}
description={t('Additional metered capability')}
source={item}
meteredFeature={item.metered_feature}
/>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Raw JSON collapsible */}
<div className='rounded-lg border'>
<button
type='button'
className='hover:bg-muted/40 flex w-full items-center justify-between gap-2 p-3 transition-colors'
onClick={() => setShowRawJson((v) => !v)}
>
<div className='text-sm font-medium'>{t('Raw JSON')}</div>
{showRawJson ? (
<ChevronUp className='text-muted-foreground h-4 w-4' />
) : (
<ChevronDown className='text-muted-foreground h-4 w-4' />
)}
</button>
{showRawJson && (
<>
<div className='flex justify-end border-t px-3 py-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => copyToClipboard(rawJsonText)}
disabled={!rawJsonText}
>
{copiedText === rawJsonText ? (
<Check className='mr-1.5 h-3.5 w-3.5 text-green-600' />
) : (
<Copy className='mr-1.5 h-3.5 w-3.5' />
)}
{t('Copy')}
</Button>
</div>
<ScrollArea className='max-h-[50vh]'>
<pre className='bg-muted/30 m-0 p-3 text-xs break-words whitespace-pre-wrap'>
{rawJsonText || '-'}
</pre>
</ScrollArea>
</>
)}
</div>
</div>
<DialogFooter>
<Button <Button
type='button' type='button'
variant='outline' variant='outline'
@@ -592,8 +431,166 @@ export function CodexUsageDialog({
> >
{t('Close')} {t('Close')}
</Button> </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> </Dialog>
) )
} }
@@ -22,16 +22,9 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { handleCopyChannel } from '../../lib' import { handleCopyChannel } from '../../lib'
import { useChannels } from '../channels-provider' import { useChannels } from '../channels-provider'
@@ -74,45 +67,20 @@ export function CopyChannelDialog({
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog
<DialogContent> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle>{t('Copy Channel')}</DialogTitle> title={t('Copy Channel')}
<DialogDescription> description={
{t('Create a copy of:')} <strong>{currentRow.name}</strong> <>
</DialogDescription> {t('Create a copy of:')}
</DialogHeader> <strong>{currentRow.name}</strong>
</>
<div className='space-y-4 py-4'> }
<div className='space-y-2'> contentHeight='auto'
<Label htmlFor='suffix'>{t('Name Suffix')}</Label> bodyClassName='space-y-4'
<Input footer={
id='suffix' <>
placeholder={t('_copy')}
value={suffix}
onChange={(e) => setSuffix(e.target.value)}
disabled={isCopying}
/>
<p className='text-muted-foreground text-xs'>
{t('New name will be:')} {currentRow.name}
{suffix}
</p>
</div>
<div className='flex items-center space-x-2'>
<Checkbox
id='reset-balance'
checked={resetBalance}
onCheckedChange={(checked) => setResetBalance(!!checked)}
disabled={isCopying}
/>
<Label htmlFor='reset-balance' className='text-sm font-normal'>
{t('Reset balance and used quota')}
</Label>
</div>
</div>
<DialogFooter>
<Button <Button
variant='outline' variant='outline'
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
@@ -122,10 +90,39 @@ export function CopyChannelDialog({
</Button> </Button>
<Button onClick={handleCopy} disabled={isCopying}> <Button onClick={handleCopy} disabled={isCopying}>
{isCopying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />} {isCopying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isCopying ? 'Copying...' : 'Copy Channel'} {isCopying ? t('Copying...') : t('Copy Channel')}
</Button> </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> </Dialog>
) )
} }
@@ -22,14 +22,6 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
@@ -43,6 +35,7 @@ import {
} from '@/components/ui/select' } from '@/components/ui/select'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { GroupBadge } from '@/components/group-badge' import { GroupBadge } from '@/components/group-badge'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { import {
@@ -222,216 +215,23 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
if (!currentTag) return null if (!currentTag) return null
return ( return (
<Dialog open={open} onOpenChange={handleClose}> <Dialog
<DialogContent className='max-h-[90vh] max-w-2xl'> open={open}
<DialogHeader> onOpenChange={handleClose}
<DialogTitle> title={
{t('Edit Tag:')} {currentTag} <>
</DialogTitle> {t('Edit Tag:')}
<DialogDescription> {currentTag}
{t( </>
'Batch edit all channels with this tag. Leave fields empty to keep current values.' }
)} description={t(
</DialogDescription> 'Batch edit all channels with this tag. Leave fields empty to keep current values.'
</DialogHeader> )}
contentClassName='max-h-[90vh] max-w-2xl'
<ScrollArea className='max-h-[60vh] pr-4'> contentHeight='auto'
<div className='space-y-6'> bodyClassName='space-y-4'
{/* Tag Name */} footer={
<div className='space-y-2'> <>
<Label htmlFor='new-tag'>
{t('Tag Name')}
<span className='text-muted-foreground ml-2 text-xs'>
{t('(Leave empty to dissolve tag)')}
</span>
</Label>
<Input
id='new-tag'
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder={t('Enter new tag name or leave empty')}
/>
</div>
<Separator />
{/* Models */}
<div className='space-y-2'>
<Label>
{t('Models')}
<span className='text-muted-foreground ml-2 text-xs'>
{t("(Override all channels' models)")}
</span>
</Label>
{isLoadingTagModels ? (
<div className='flex items-center gap-2 py-4'>
<Loader2 className='h-4 w-4 animate-spin' />
<span className='text-muted-foreground text-sm'>
{t('Loading current models...')}
</span>
</div>
) : (
<>
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
{selectedModels.length > 0 ? (
selectedModels.map((model) => (
<StatusBadge
key={model}
variant='neutral'
className='cursor-pointer transition-opacity hover:opacity-70'
copyable={false}
onClick={() => handleRemoveModel(model)}
>
{model} ×
</StatusBadge>
))
) : (
<span className='text-muted-foreground text-sm'>
{t('No models selected')}
</span>
)}
</div>
<div className='flex gap-2'>
<Select<string>
items={[
...availableModels.map((model) => ({
value: model,
label: model,
})),
]}
onValueChange={(value) => {
if (value === null) return
if (!selectedModels.includes(value)) {
setSelectedModels([...selectedModels, value])
}
}}
>
<SelectTrigger className='flex-1'>
<SelectValue
placeholder={t('Add from available models...')}
/>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<ScrollArea className='h-60'>
{availableModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</ScrollArea>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='flex gap-2'>
<Input
placeholder={t('Custom model (comma-separated)')}
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddCustomModel()
}
}}
/>
<Button
type='button'
variant='secondary'
onClick={handleAddCustomModel}
>
{t('Add')}
</Button>
</div>
</>
)}
</div>
<Separator />
{/* Model Mapping */}
<div className='space-y-2'>
<Label htmlFor='model-mapping'>
{t('Model Mapping (JSON)')}
<span className='text-muted-foreground ml-2 text-xs'>
{t('(Optional: redirect model names)')}
</span>
</Label>
<Textarea
id='model-mapping'
value={modelMapping}
onChange={(e) => setModelMapping(e.target.value)}
placeholder={'{\n "gpt-3.5-turbo": "gpt-3.5-turbo-0125"\n}'}
rows={4}
className='font-mono text-sm'
/>
<div className='flex gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
setModelMapping(
JSON.stringify(
{ 'gpt-3.5-turbo': 'gpt-3.5-turbo-0125' },
null,
2
)
)
}
>
{t('Example')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setModelMapping(JSON.stringify({}, null, 2))}
>
{t('Clear Mapping')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setModelMapping('')}
>
{t('No Change')}
</Button>
</div>
</div>
<Separator />
{/* Groups */}
<div className='space-y-2'>
<Label>
{t('Groups')}
<span className='text-muted-foreground ml-2 text-xs'>
{t("(Override all channels' groups)")}
</span>
</Label>
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
{availableGroups.map((group) => (
<GroupBadge
key={group}
group={group}
className={`cursor-pointer rounded-sm transition-opacity hover:opacity-70 ${
selectedGroups.includes(group) ? 'bg-muted/70 px-1' : ''
}`}
onClick={() => handleToggleGroup(group)}
/>
))}
</div>
</div>
</div>
</ScrollArea>
<DialogFooter>
<Button variant='outline' onClick={handleClose}> <Button variant='outline' onClick={handleClose}>
{t('Cancel')} {t('Cancel')}
</Button> </Button>
@@ -439,8 +239,204 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
{isSubmitting && <Loader2 className='mr-2 h-4 w-4 animate-spin' />} {isSubmitting && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{t('Save Changes')} {t('Save Changes')}
</Button> </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> </Dialog>
) )
} }
@@ -28,14 +28,6 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
@@ -44,6 +36,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { Dialog } from '@/components/dialog'
import { fetchUpstreamModels, updateChannel } from '../../api' import { fetchUpstreamModels, updateChannel } from '../../api'
import { import {
channelsQueryKeys, channelsQueryKeys,
@@ -365,152 +358,153 @@ export function FetchModelsDialog({
) )
} }
const showFooterActions =
!!(activeChannel || customFetcher) &&
!isFetching &&
(fetchedModels.length > 0 || removedModels.length > 0)
return ( return (
<Dialog open={open} onOpenChange={handleClose}> <Dialog
<DialogContent className='max-w-3xl'> open={open}
<DialogHeader> onOpenChange={handleClose}
<DialogTitle>{t('Fetch Models')}</DialogTitle> title={t('Fetch Models')}
<DialogDescription> description={
{activeChannel ? ( 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>
) : (
<> <>
<div className='space-y-4'> {t('Channel:')} <strong>{activeChannel.name}</strong>
{/* Search Bar */} </>
<div className='relative'> ) : channelName ? (
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' /> <>
<Input {t('Channel:')} <strong>{channelName}</strong>
placeholder={t('Search models...')} </>
value={searchKeyword} ) : (
onChange={(e) => setSearchKeyword(e.target.value)} t('Fetch available models from upstream')
className='pl-9' )
/> }
</div> contentClassName='max-w-3xl'
contentHeight='auto'
{/* Tabs for New vs Existing vs Removed */} bodyClassName='space-y-4'
<Tabs footer={
key={`${activeChannel?.id ?? 'custom'}-${fetchedModels.length}-${removedModels.length}`} showFooterActions ? (
defaultValue={ <>
newModels.length > 0 <Button variant='outline' onClick={handleClose} disabled={isSaving}>
? 'new' {t('Cancel')}
: removedModels.length > 0 </Button>
? 'removed' <Button onClick={handleSave} disabled={isSaving}>
: 'existing' {isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
} {isSaving ? t('Saving...') : t('Save Models')}
> </Button>
<TabsList </>
className={`grid w-full ${removedModels.length > 0 ? 'grid-cols-3' : 'grid-cols-2'}`} ) : null
> }
<TabsTrigger value='new' disabled={newModels.length === 0}> >
{t('New Models ({{count}})', { count: newModels.length })} {!activeChannel && !customFetcher ? (
</TabsTrigger> <div className='text-muted-foreground py-8 text-center'>
<TabsTrigger {t('No channel selected')}
value='existing' </div>
disabled={existingFilteredModels.length === 0} ) : isFetching ? (
> <div className='flex items-center justify-center py-12'>
{t('Existing Models ({{count}})', { <Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
count: existingFilteredModels.length, </div>
})} ) : fetchedModels.length === 0 && removedModels.length === 0 ? (
</TabsTrigger> <div className='text-muted-foreground py-8 text-center'>
{removedModels.length > 0 && ( <p>{t('No models fetched yet.')}</p>
<TabsTrigger value='removed'> <Button
{t('Removed Models ({{count}})', { className='mt-4'
count: removedModels.length, onClick={handleFetchModels}
})} disabled={isFetching}
</TabsTrigger> >
)} {t('Fetch Models')}
</TabsList> </Button>
</div>
<TabsContent ) : (
value='new' <>
className='max-h-96 space-y-2 overflow-y-auto' <div className='space-y-4'>
> {/* Search Bar */}
{getSortedCategoryEntries(newModelsByCategory).map( <div className='relative'>
([category, models]) => <Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
renderModelCategory(category, models) <Input
)} placeholder={t('Search models...')}
</TabsContent> value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
<TabsContent className='pl-9'
value='existing' />
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(existingModelsByCategory).map(
([category, models]) =>
renderModelCategory(category, models)
)}
</TabsContent>
{removedModels.length > 0 && (
<TabsContent
value='removed'
className='max-h-96 space-y-2 overflow-y-auto'
>
<p className='text-muted-foreground text-xs'>
{t(
'These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.'
)}
</p>
{renderModelCategory(t('Removed'), removedModels)}
</TabsContent>
)}
</Tabs>
{/* Selection Summary */}
<div className='bg-muted/50 rounded-lg border p-3 text-sm'>
{t('{{n}} model(s) selected', { n: selectedModels.length })}
</div>
</div> </div>
<DialogFooter> {/* Tabs for New vs Existing vs Removed */}
<Button <Tabs
variant='outline' key={`${activeChannel?.id ?? 'custom'}-${fetchedModels.length}-${removedModels.length}`}
onClick={handleClose} defaultValue={
disabled={isSaving} 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')} <TabsTrigger value='new' disabled={newModels.length === 0}>
</Button> {t('New Models ({{count}})', { count: newModels.length })}
<Button onClick={handleSave} disabled={isSaving}> </TabsTrigger>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />} <TabsTrigger
{isSaving ? t('Saving...') : t('Save Models')} value='existing'
</Button> disabled={existingFilteredModels.length === 0}
</DialogFooter> >
</> {t('Existing Models ({{count}})', {
)} count: existingFilteredModels.length,
</DialogContent> })}
</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> </Dialog>
) )
} }
@@ -22,13 +22,6 @@ import { Loader2, RefreshCw, Trash2, Power, PowerOff } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -38,15 +31,9 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { StaticDataTable } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { import {
getMultiKeyStatus, getMultiKeyStatus,
@@ -228,215 +215,222 @@ export function MultiKeyManageDialog({
return ( return (
<> <>
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog
<DialogContent className='flex max-h-[90vh] max-w-5xl flex-col'> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle className='flex items-center gap-2'> title={
{t('Multi-Key Management')} <>
{t('Multi-Key Management')}
<StatusBadge
label={currentRow.name}
variant='neutral'
copyable={false}
/>
{currentRow.channel_info?.multi_key_mode && (
<StatusBadge <StatusBadge
label={currentRow.name} label={
currentRow.channel_info.multi_key_mode === 'random'
? t('Random')
: t('Polling')
}
variant='neutral' variant='neutral'
copyable={false} copyable={false}
/> />
{currentRow.channel_info?.multi_key_mode && ( )}
<StatusBadge </>
label={ }
currentRow.channel_info.multi_key_mode === 'random' description={t(
? t('Random') 'Manage multi-key status and configuration for this channel'
: t('Polling') )}
} contentClassName='flex max-h-[90vh] max-w-5xl flex-col'
variant='neutral' titleClassName='flex items-center gap-2'
copyable={false} contentHeight='min(72vh, 720px)'
/> bodyClassName='space-y-4'
)} >
</DialogTitle> <div className='flex min-h-0 flex-1 flex-col space-y-4 overflow-hidden'>
<DialogDescription> {/* Statistics */}
{t('Manage multi-key status and configuration for this channel')} <div className='grid shrink-0 grid-cols-3 gap-3'>
</DialogDescription> <StatisticsCard
</DialogHeader> 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'> <Separator className='shrink-0' />
{/* Statistics */}
<div className='grid shrink-0 grid-cols-3 gap-3'>
<StatisticsCard
label={t('Enabled')}
count={enabledCount}
total={total}
/>
<StatisticsCard
label={t('Manual Disabled')}
count={manualDisabledCount}
total={total}
/>
<StatisticsCard
label={t('Auto Disabled')}
count={autoDisabledCount}
total={total}
/>
</div>
<Separator className='shrink-0' /> {/* Toolbar */}
<div className='flex shrink-0 items-center justify-between'>
<Select
items={[
...MULTI_KEY_FILTER_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
})),
]}
value={statusFilter === null ? 'all' : statusFilter.toString()}
onValueChange={(v) => v !== null && handleStatusFilterChange(v)}
>
<SelectTrigger className='w-40'>
<SelectValue placeholder={t('All Status')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{MULTI_KEY_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{/* Toolbar */} <div className='flex items-center gap-2'>
<div className='flex shrink-0 items-center justify-between'> <Button
<Select variant='outline'
items={[ size='sm'
...MULTI_KEY_FILTER_OPTIONS.map((option) => ({ onClick={() => loadKeyStatus()}
value: option.value, disabled={isLoading}
label: t(option.label),
})),
]}
value={statusFilter === null ? 'all' : statusFilter.toString()}
onValueChange={(v) => v !== null && handleStatusFilterChange(v)}
> >
<SelectTrigger className='w-40'> <RefreshCw className='h-4 w-4' />
<SelectValue placeholder={t('All Status')} /> </Button>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{MULTI_KEY_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<div className='flex items-center gap-2'> {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 <Button
variant='outline' variant='outline'
size='sm' size='sm'
onClick={() => loadKeyStatus()} onClick={() => handlePageChange(currentPage - 1)}
disabled={isLoading} 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> </Button>
{manualDisabledCount + autoDisabledCount > 0 && (
<Button
variant='default'
size='sm'
onClick={() => setConfirmAction({ type: 'enable-all' })}
>
<Power className='mr-2 h-4 w-4' />
{t('Enable All')}
</Button>
)}
{enabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() => setConfirmAction({ type: 'disable-all' })}
>
<PowerOff className='mr-2 h-4 w-4' />
{t('Disable All')}
</Button>
)}
{autoDisabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() =>
setConfirmAction({ type: 'delete-disabled' })
}
>
<Trash2 className='mr-2 h-4 w-4' />
{t('Delete Auto-Disabled')}
</Button>
)}
</div> </div>
</div> </div>
)}
{/* Table */} </div>
<div className='min-h-0 flex-1 overflow-auto rounded-md border'>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : keys.length === 0 ? (
<div className='text-muted-foreground py-12 text-center'>
{t('No keys found')}
</div>
) : (
<div className='min-w-[800px]'>
<Table>
<TableHeader>
<TableRow>
<TableHead className='w-20'>{t('Index')}</TableHead>
<TableHead className='w-32'>{t('Status')}</TableHead>
<TableHead className='min-w-[200px]'>
{t('Disabled Reason')}
</TableHead>
<TableHead className='w-44'>
{t('Disabled Time')}
</TableHead>
<TableHead className='w-44 text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{keys.map((key) => (
<TableRow key={key.index}>
<TableCell className='font-mono text-sm'>
#{key.index + 1}
</TableCell>
<TableCell>{renderStatusBadge(key.status)}</TableCell>
<TableCell className='max-w-xs truncate text-sm'>
{key.reason || '-'}
</TableCell>
<TableCell className='text-muted-foreground text-sm'>
{formatKeyTimestamp(key.disabled_time)}
</TableCell>
<TableCell>
<MultiKeyTableRowActions
keyIndex={key.index}
status={key.status}
onAction={setConfirmAction}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className='flex shrink-0 items-center justify-between'>
<div className='text-muted-foreground text-sm'>
{t('Page {{current}} of {{total}}', {
current: currentPage,
total: totalPages,
})}
</div>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || isLoading}
>
{t('Previous')}
</Button>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages || isLoading}
>
{t('Next')}
</Button>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog> </Dialog>
{/* Confirmation Dialog */} {/* Confirmation Dialog */}
@@ -34,18 +34,11 @@ import {
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Dialog } from '@/components/dialog'
import { import {
deleteOllamaModel, deleteOllamaModel,
fetchModels as fetchModelsFromEndpoint, fetchModels as fetchModelsFromEndpoint,
@@ -375,203 +368,203 @@ export function OllamaModelsDialog({
if (!open) return null if (!open) return null
return ( return (
<Dialog open={open} onOpenChange={close}> <Dialog
<DialogContent className='max-h-[90vh] overflow-hidden sm:max-w-3xl'> open={open}
<DialogHeader> onOpenChange={close}
<DialogTitle>{t('Ollama Models')}</DialogTitle> title={t('Ollama Models')}
<DialogDescription> description={
{t('Manage local models for:')} <strong>{currentRow?.name}</strong> <>
</DialogDescription> {t('Manage local models for:')} <strong>{currentRow?.name}</strong>
</DialogHeader> </>
}
{!isOllamaChannel ? ( contentClassName='sm:max-w-3xl'
<div className='text-muted-foreground py-8 text-center'> contentHeight='auto'
{t('This channel is not an Ollama channel.')} bodyClassName='space-y-4'
</div> footer={
) : ( <Button variant='outline' onClick={close}>
<div className='max-h-[78vh] space-y-4 overflow-y-auto py-2 pr-1'> {t('Close')}
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'> </Button>
<div className='flex-1 space-y-2'> }
<Label htmlFor='ollama-pull'>{t('Pull model')}</Label> >
<div className='flex gap-2'> {!isOllamaChannel ? (
<Input <div className='text-muted-foreground py-8 text-center'>
id='ollama-pull' {t('This channel is not an Ollama channel.')}
placeholder={t('e.g. llama3.1:8b')} </div>
value={pullName} ) : (
onChange={(e) => setPullName(e.target.value)} <div className='space-y-4 py-2 pr-1'>
disabled={!channelId || isPulling} <div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
/> <div className='flex-1 space-y-2'>
<Button <Label htmlFor='ollama-pull'>{t('Pull model')}</Label>
onClick={() => void pullModel()} <div className='flex gap-2'>
disabled={!channelId || isPulling} <Input
> id='ollama-pull'
{isPulling ? ( placeholder={t('e.g. llama3.1:8b')}
<> value={pullName}
<Loader2 className='mr-2 h-4 w-4 animate-spin' /> onChange={(e) => setPullName(e.target.value)}
{t('Pulling...')} disabled={!channelId || isPulling}
</> />
) : ( <Button
<> onClick={() => void pullModel()}
<Download className='mr-2 h-4 w-4' /> disabled={!channelId || isPulling}
{t('Pull')} >
</> {isPulling ? (
)} <>
</Button> <Loader2 className='mr-2 h-4 w-4 animate-spin' />
</div> {t('Pulling...')}
{pullProgress && ( </>
<div className='space-y-2'> ) : (
<div className='text-muted-foreground text-xs'> <>
{t('Status:')} {String(pullProgress.status || '-')} <Download className='mr-2 h-4 w-4' />
</div> {t('Pull')}
<Progress </>
value={ )}
typeof pullProgress.completed === 'number' && </Button>
typeof pullProgress.total === 'number' && </div>
pullProgress.total > 0 {pullProgress && (
? Math.min( <div className='space-y-2'>
100, <div className='text-muted-foreground text-xs'>
Math.round( {t('Status:')} {String(pullProgress.status || '-')}
(pullProgress.completed / pullProgress.total) * </div>
100 <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> </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>
</div> </div>
)} </div>
)}
<DialogFooter>
<Button variant='outline' onClick={close}>
{t('Close')}
</Button>
</DialogFooter>
</DialogContent>
<AlertDialog <AlertDialog
open={deleteOpen} open={deleteOpen}
@@ -43,14 +43,6 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { import {
@@ -63,6 +55,7 @@ import {
} from '@/components/ui/select' } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -1701,356 +1694,20 @@ export function ParamOverrideEditorDialog(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
return ( return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}> <Dialog
<DialogContent className='flex max-h-[90vh] flex-col gap-0 p-0 sm:max-w-5xl'> open={props.open}
<DialogHeader className='border-b px-6 py-4'> onOpenChange={props.onOpenChange}
<DialogTitle>{t('Parameter Override')}</DialogTitle> title={t('Parameter Override')}
<DialogDescription> description={t(
{t( 'Create request parameter override rules with a visual editor or raw JSON.'
'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'
</DialogDescription> headerClassName='border-b px-6 py-4'
</DialogHeader> footerClassName='border-t px-6 py-4'
contentHeight='min(72vh, 720px)'
{/* Toolbar */} bodyClassName='space-y-4'
<div className='bg-muted/30 border-b px-4 py-3'> footer={
<div className='flex flex-wrap items-center gap-2'> <>
<span className='text-muted-foreground text-xs font-medium'>
{t('Mode')}
</span>
<Button
type='button'
variant={editMode === 'visual' ? 'default' : 'outline'}
size='sm'
onClick={switchToVisualMode}
>
{t('Visual')}
</Button>
<Button
type='button'
variant={editMode === 'json' ? 'default' : 'outline'}
size='sm'
onClick={switchToJsonMode}
>
{t('JSON Text')}
</Button>
<div className='bg-border mx-1 h-5 w-px' />
<span className='text-muted-foreground text-xs font-medium'>
{t('Template')}
</span>
<Select
items={[
...templatePresetOptions.map((o) => ({
value: o.value,
label: t(o.label),
})),
]}
value={templatePresetKey}
onValueChange={(v) =>
setTemplatePresetKey(v || 'operations_default')
}
>
<SelectTrigger className='h-8 w-[220px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{templatePresetOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => fillTemplate('fill')}
>
{t('Fill Template')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => fillTemplate('append')}
>
{t('Append Template')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={resetEditorState}
>
{t('Reset')}
</Button>
</div>
</div>
{/* Content */}
<div className='min-h-0 flex-1 overflow-hidden'>
{editMode === 'visual' ? (
visualMode === 'legacy' ? (
<div className='p-4'>
<p className='text-muted-foreground mb-2 text-sm'>
{t('Legacy Format (JSON Object)')}
</p>
<Textarea
value={legacyValue}
onChange={(e) => setLegacyValue(e.target.value)}
placeholder={JSON.stringify(LEGACY_TEMPLATE, null, 2)}
rows={14}
className='font-mono text-xs'
/>
<p className='text-muted-foreground mt-2 text-xs'>
{t(
'Edit JSON object directly. Suitable for simple parameter overrides.'
)}
</p>
</div>
) : (
<div className='flex h-full'>
{/* Left sidebar */}
<div className='flex w-[280px] flex-shrink-0 flex-col border-r'>
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<span className='text-sm font-medium'>{t('Rules')}</span>
<Badge variant='secondary'>
{operationCount}/{operations.length}
</Badge>
</div>
<Button
type='button'
variant='ghost'
size='sm'
onClick={addOperation}
>
<Plus className='h-4 w-4' />
</Button>
</div>
{topOperationModes.length > 0 && (
<div className='flex flex-wrap gap-1 border-b px-3 py-2'>
{topOperationModes.map(([mode, count]) => (
<span
key={`mode_stat_${mode}`}
className={cn(
'inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium',
getModeTagTailwind(mode)
)}
>
{t(OPERATION_MODE_LABEL_MAP[mode] || mode)} · {count}
</span>
))}
</div>
)}
<div className='px-3 py-2'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-3.5 w-3.5' />
<Input
value={operationSearch}
onChange={(e) => setOperationSearch(e.target.value)}
placeholder={t('Search rules...')}
className='h-8 pl-8 text-xs'
/>
</div>
</div>
<ScrollArea className='flex-1'>
<div className='flex flex-col gap-1 px-3 pb-3'>
{filteredOperations.length === 0 ? (
<p className='text-muted-foreground py-4 text-center text-xs'>
{t('No matching rules')}
</p>
) : (
filteredOperations.map((operation) => {
const index = operations.findIndex(
(o) => o.id === operation.id
)
const isActive = operation.id === selectedOperationId
const isDragging = operation.id === draggedOperationId
const isDropTarget =
operation.id === dragOverOperationId &&
draggedOperationId !== '' &&
draggedOperationId !== operation.id
return (
<div
key={operation.id}
role='button'
tabIndex={0}
draggable={operations.length > 1}
onClick={() =>
setSelectedOperationId(operation.id)
}
onDragStart={(e) =>
handleDragStart(e, operation.id)
}
onDragOver={(e) =>
handleDragOver(e, operation.id)
}
onDrop={(e) => handleDrop(e, operation.id)}
onDragEnd={resetDragState}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setSelectedOperationId(operation.id)
}
}}
className={cn(
'cursor-pointer rounded-lg border p-2.5 transition-colors',
isActive
? 'border-primary bg-primary/5'
: 'hover:bg-muted/50',
isDragging && 'opacity-50',
isDropTarget &&
dragOverPosition === 'before' &&
'border-t-primary border-t-2',
isDropTarget &&
dragOverPosition === 'after' &&
'border-b-primary border-b-2'
)}
>
<div className='flex items-start gap-2'>
<GripVertical
className={cn(
'text-muted-foreground mt-0.5 h-3.5 w-3.5 flex-shrink-0',
operations.length > 1
? 'cursor-grab'
: 'cursor-default'
)}
/>
<div className='min-w-0 flex-1'>
<div className='flex items-center justify-between gap-1'>
<span className='text-xs font-semibold'>
#{index + 1}
</span>
<Badge
variant='outline'
className='text-[10px]'
>
{operation.conditions.length}
</Badge>
</div>
<p className='text-muted-foreground mt-0.5 line-clamp-1 text-[11px]'>
{getOperationSummary(operation, index)}
</p>
{operation.description.trim() && (
<p className='text-muted-foreground mt-0.5 line-clamp-2 text-[10px]'>
{operation.description}
</p>
)}
<span
className={cn(
'mt-1 inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium',
getModeTagTailwind(
operation.mode || 'set'
)
)}
>
{t(
OPERATION_MODE_LABEL_MAP[
operation.mode || 'set'
] ||
operation.mode ||
'set'
)}
</span>
</div>
</div>
</div>
)
})
)}
</div>
</ScrollArea>
</div>
{/* Right panel - Rule editor */}
<div className='flex min-w-0 flex-1 flex-col overflow-y-auto'>
{selectedOperation ? (
<RuleEditor
operation={selectedOperation}
operationIndex={selectedOperationIndex}
operations={operations}
returnErrorDraft={returnErrorDraft}
pruneObjectsDraft={pruneObjectsDraft}
expandedConditions={expandedConditions}
setExpandedConditions={setExpandedConditions}
updateOperation={updateOperation}
duplicateOperation={duplicateOperation}
removeOperation={removeOperation}
addCondition={addCondition}
updateCondition={updateCondition}
removeCondition={removeCondition}
updateReturnErrorDraft={updateReturnErrorDraft}
updatePruneObjectsDraft={updatePruneObjectsDraft}
addPruneRule={addPruneRule}
updatePruneRule={updatePruneRule}
removePruneRule={removePruneRule}
expandAllConditions={expandAllConditions}
collapseAllConditions={collapseAllConditions}
/>
) : (
<div className='flex flex-1 items-center justify-center'>
<p className='text-muted-foreground text-sm'>
{t('Select a rule to edit.')}
</p>
</div>
)}
{visualValidationError && (
<div className='border-t px-4 py-2'>
<p className='text-destructive text-xs'>
{visualValidationError}
</p>
</div>
)}
</div>
</div>
)
) : (
/* JSON mode */
<div className='p-4'>
<div className='mb-2 flex items-center gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={formatJson}
>
{t('Format')}
</Button>
<span className='text-muted-foreground text-xs'>
{t('Advanced text editing')}
</span>
</div>
<Textarea
value={jsonText}
onChange={(e) => handleJsonChange(e.target.value)}
placeholder={JSON.stringify(OPERATION_TEMPLATE, null, 2)}
rows={20}
className='font-mono text-xs'
/>
<p className='text-muted-foreground mt-2 text-xs'>
{t(
'Edit JSON text directly. Format will be validated on save.'
)}
</p>
{jsonError && (
<p className='text-destructive mt-1 text-xs'>{jsonError}</p>
)}
</div>
)}
</div>
{/* Footer */}
<DialogFooter className='border-t px-6 py-4'>
<Button <Button
type='button' type='button'
variant='outline' variant='outline'
@@ -2061,8 +1718,337 @@ export function ParamOverrideEditorDialog(
<Button type='button' onClick={handleSave}> <Button type='button' onClick={handleSave}>
{t('Save')} {t('Save')}
</Button> </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> </Dialog>
) )
} }
@@ -21,16 +21,9 @@ import { AlertTriangle } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
interface StatusCodeRiskDialogProps { interface StatusCodeRiskDialogProps {
open: boolean open: boolean
@@ -84,73 +77,22 @@ export function StatusCodeRiskDialog({
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog
<DialogContent className='max-w-lg'> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle className='text-destructive flex items-center gap-2'> title={
<AlertTriangle className='h-5 w-5' /> <>
{t('High-risk operation confirmation')} <AlertTriangle className='h-5 w-5' />
</DialogTitle> {t('High-risk operation confirmation')}
<DialogDescription> </>
{t('High-risk status code retry risk disclaimer')} }
</DialogDescription> description={t('High-risk status code retry risk disclaimer')}
</DialogHeader> contentClassName='max-w-lg'
titleClassName='text-destructive flex items-center gap-2'
<div className='space-y-4'> contentHeight='auto'
{detailItems.length > 0 && ( bodyClassName='space-y-4'
<div className='border-destructive/30 bg-destructive/5 rounded-lg border p-3'> footer={
<p className='mb-2 text-sm font-medium'> <>
{t('Detected high-risk status code redirect rules')}
</p>
<ul className='list-inside list-disc text-sm'>
{detailItems.map((item) => (
<li key={item} className='font-mono text-xs'>
{item}
</li>
))}
</ul>
</div>
)}
<div className='space-y-2'>
{CHECKLIST_KEYS.map((key, idx) => (
<div key={key} className='flex items-start gap-2'>
<Checkbox
id={`risk-check-${idx}`}
checked={checkedItems.has(idx)}
onCheckedChange={() => toggleCheck(idx)}
/>
<Label
htmlFor={`risk-check-${idx}`}
className='text-sm leading-tight'
>
{t(key)}
</Label>
</div>
))}
</div>
<div className='space-y-1.5'>
<Label className='text-sm'>
{t('Action confirmation')}:{' '}
<code className='bg-muted rounded px-1 text-xs'>
{requiredText}
</code>
</Label>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={t('High-risk status code retry input placeholder')}
/>
{confirmText && !textMatches && (
<p className='text-destructive text-xs'>
{t('High-risk status code retry input mismatch')}
</p>
)}
</div>
</div>
<DialogFooter>
<Button variant='outline' onClick={handleCancel}> <Button variant='outline' onClick={handleCancel}>
{t('Cancel')} {t('Cancel')}
</Button> </Button>
@@ -161,8 +103,62 @@ export function StatusCodeRiskDialog({
> >
{t('I confirm enabling high-risk retry')} {t('I confirm enabling high-risk retry')}
</Button> </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> </Dialog>
) )
} }
@@ -23,18 +23,11 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { MultiSelect } from '@/components/multi-select' import { MultiSelect } from '@/components/multi-select'
import { import {
getTagModels, getTagModels,
@@ -190,115 +183,118 @@ export function TagBatchEditDialog({
if (!currentTag) return null if (!currentTag) return null
return ( return (
<Dialog open={open} onOpenChange={handleClose}> <Dialog
<DialogContent className='max-h-[90vh] max-w-2xl overflow-y-auto'> open={open}
<DialogHeader> onOpenChange={handleClose}
<DialogTitle>{t('Batch Edit by Tag')}</DialogTitle> title={t('Batch Edit by Tag')}
<DialogDescription> description={
{t('Edit all channels with tag:')} <strong>{currentTag}</strong> <>
</DialogDescription> {t('Edit all channels with tag:')}
</DialogHeader> <strong>{currentTag}</strong>
</>
{isLoading ? ( }
<div className='flex items-center justify-center py-12'> contentClassName='max-w-2xl'
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' /> contentHeight='auto'
</div> bodyClassName='space-y-4'
) : ( footer={
!isLoading ? (
<> <>
<div className='space-y-4 py-4'> <Button variant='outline' onClick={handleClose} disabled={isSaving}>
<Alert> {t('Cancel')}
<AlertCircle className='h-4 w-4' /> </Button>
<AlertDescription> <Button onClick={handleSave} disabled={isSaving}>
{t( {isSaving ? (
'All edits are overwrite operations. Leave fields empty to keep current values unchanged.' <Loader2 className='mr-2 h-4 w-4 animate-spin' />
)} ) : null}
</AlertDescription> {isSaving ? t('Saving...') : t('Save Changes')}
</Alert> </Button>
</>
{/* Tag Name */} ) : null
<div className='space-y-2'> }
<Label htmlFor='new-tag'>{t('Tag Name')}</Label> >
<Input {isLoading ? (
id='new-tag' <div className='flex items-center justify-center py-12'>
placeholder={t( <Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
'Enter new tag name (leave empty to disband tag)' </div>
)} ) : (
value={newTag} <>
onChange={(e) => setNewTag(e.target.value)} <div className='space-y-4 py-4'>
disabled={isSaving} <Alert>
/> <AlertCircle className='h-4 w-4' />
<p className='text-muted-foreground text-xs'> <AlertDescription>
{t('Leave empty to disband the tag')} {t(
</p> 'All edits are overwrite operations. Leave fields empty to keep current values unchanged.'
</div>
{/* Models */}
<div className='space-y-2'>
<Label htmlFor='models'>{t('Models')}</Label>
<Textarea
id='models'
placeholder={t(
'Comma-separated model names (leave empty to keep current)'
)}
value={models}
onChange={(e) => setModels(e.target.value)}
disabled={isSaving}
rows={3}
/>
<p className='text-muted-foreground text-xs'>
{t(
'Current models for the longest channel in this tag. May not include all models from all channels.'
)}
</p>
</div>
{/* Model Mapping */}
<div className='space-y-2'>
<Label htmlFor='model-mapping'>{t('Model Mapping')}</Label>
<ModelMappingEditor
value={modelMapping}
onChange={setModelMapping}
disabled={isSaving}
/>
</div>
{/* Groups */}
<div className='space-y-2'>
<Label htmlFor='groups'>{t('Groups')}</Label>
{isLoadingGroups ? (
<Skeleton className='h-10 w-full' />
) : (
<MultiSelect
options={groupOptions}
selected={groups}
onChange={setGroups}
placeholder={t(
'Select groups (leave empty to keep current)'
)}
/>
)} )}
<p className='text-muted-foreground text-xs'> </AlertDescription>
{t('User groups that can access channels with this tag')} </Alert>
</p>
</div> {/* 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> </div>
<DialogFooter> {/* Models */}
<Button <div className='space-y-2'>
variant='outline' <Label htmlFor='models'>{t('Models')}</Label>
onClick={handleClose} <Textarea
id='models'
placeholder={t(
'Comma-separated model names (leave empty to keep current)'
)}
value={models}
onChange={(e) => setModels(e.target.value)}
disabled={isSaving} disabled={isSaving}
> rows={3}
{t('Cancel')} />
</Button> <p className='text-muted-foreground text-xs'>
<Button onClick={handleSave} disabled={isSaving}> {t(
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />} 'Current models for the longest channel in this tag. May not include all models from all channels.'
{isSaving ? t('Saving...') : t('Save Changes')} )}
</Button> </p>
</DialogFooter> </div>
</>
)} {/* Model Mapping */}
</DialogContent> <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> </Dialog>
) )
} }
@@ -21,17 +21,11 @@ import { Search } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
interface UpstreamUpdateDialogProps { interface UpstreamUpdateDialogProps {
@@ -120,157 +114,15 @@ export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
return ( return (
<> <>
<Dialog open={props.open} onOpenChange={(v) => !v && props.onCancel()}> <Dialog
<DialogContent className='sm:max-w-lg'> open={props.open}
<DialogHeader> onOpenChange={(v) => !v && props.onCancel()}
<DialogTitle>{t('Upstream Model Updates')}</DialogTitle> title={t('Upstream Model Updates')}
</DialogHeader> contentClassName='sm:max-w-lg'
contentHeight='auto'
<p className='text-muted-foreground text-sm'> bodyClassName='space-y-4'
{t( footer={
'Select models to process. Unselected "add" models will be ignored.' <>
)}
</p>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as 'add' | 'remove')}
>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='add' className='gap-1'>
{t('Add Models')}
<StatusBadge
variant='neutral'
className='ml-1'
copyable={false}
>
{selectedAdd.size}/{props.addModels.length}
</StatusBadge>
</TabsTrigger>
<TabsTrigger value='remove' className='gap-1'>
{t('Remove Models')}
<StatusBadge
variant='neutral'
className='ml-1'
copyable={false}
>
{selectedRemove.size}/{props.removeModels.length}
</StatusBadge>
</TabsTrigger>
</TabsList>
<TabsContent value='add' className='space-y-3'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder={t('Search models...')}
className='pl-8'
value={searchAdd}
onChange={(e) => setSearchAdd(e.target.value)}
/>
</div>
{filteredAdd.length > 0 && (
<div className='flex items-center gap-2'>
<Checkbox
checked={filteredAdd.every((m) => selectedAdd.has(m))}
onCheckedChange={() =>
toggleAllVisible(filteredAdd, selectedAdd, setSelectedAdd)
}
/>
<span className='text-muted-foreground text-xs'>
{t('Select All Visible')}
</span>
</div>
)}
<ScrollArea className='h-[280px] rounded-md border p-2'>
{filteredAdd.length > 0 ? (
<div className='space-y-1'>
{filteredAdd.map((model) => (
<label
key={model}
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
>
<Checkbox
checked={selectedAdd.has(model)}
onCheckedChange={() =>
toggleModel(model, selectedAdd, setSelectedAdd)
}
/>
<span className='truncate text-sm'>{model}</span>
</label>
))}
</div>
) : (
<p className='text-muted-foreground py-8 text-center text-sm'>
{props.addModels.length === 0
? t('No models to add')
: t('No matching results')}
</p>
)}
</ScrollArea>
</TabsContent>
<TabsContent value='remove' className='space-y-3'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder={t('Search models...')}
className='pl-8'
value={searchRemove}
onChange={(e) => setSearchRemove(e.target.value)}
/>
</div>
{filteredRemove.length > 0 && (
<div className='flex items-center gap-2'>
<Checkbox
checked={filteredRemove.every((m) => selectedRemove.has(m))}
onCheckedChange={() =>
toggleAllVisible(
filteredRemove,
selectedRemove,
setSelectedRemove
)
}
/>
<span className='text-muted-foreground text-xs'>
{t('Select All Visible')}
</span>
</div>
)}
<ScrollArea className='h-[280px] rounded-md border p-2'>
{filteredRemove.length > 0 ? (
<div className='space-y-1'>
{filteredRemove.map((model) => (
<label
key={model}
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
>
<Checkbox
checked={selectedRemove.has(model)}
onCheckedChange={() =>
toggleModel(
model,
selectedRemove,
setSelectedRemove
)
}
/>
<span className='truncate text-sm'>{model}</span>
</label>
))}
</div>
) : (
<p className='text-muted-foreground py-8 text-center text-sm'>
{props.removeModels.length === 0
? t('No models to remove')
: t('No matching results')}
</p>
)}
</ScrollArea>
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant='outline' onClick={props.onCancel}> <Button variant='outline' onClick={props.onCancel}>
{t('Cancel')} {t('Cancel')}
</Button> </Button>
@@ -284,8 +136,139 @@ export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
> >
{t('Confirm')} {t('Confirm')}
</Button> </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> </Dialog>
<ConfirmDialog <ConfirmDialog
+1 -1
View File
@@ -27,7 +27,7 @@ export function Channels() {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<ChannelsProvider> <ChannelsProvider>
<SectionPageLayout> <SectionPageLayout fixedContent>
<SectionPageLayout.Title>{t('Channels')}</SectionPageLayout.Title> <SectionPageLayout.Title>{t('Channels')}</SectionPageLayout.Title>
<SectionPageLayout.Actions> <SectionPageLayout.Actions>
<ChannelsPrimaryButtons /> <ChannelsPrimaryButtons />
@@ -21,15 +21,6 @@ import { Save, Settings2 } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { TimeGranularity } from '@/lib/time' import type { TimeGranularity } from '@/lib/time'
import { Button } from '@/components/ui/button' 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 { Label } from '@/components/ui/label'
import { import {
Select, Select,
@@ -39,6 +30,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Dialog } from '@/components/dialog'
import { import {
CONSUMPTION_DISTRIBUTION_CHART_OPTIONS, CONSUMPTION_DISTRIBUTION_CHART_OPTIONS,
MODEL_ANALYTICS_CHART_OPTIONS, MODEL_ANALYTICS_CHART_OPTIONS,
@@ -74,165 +66,158 @@ export function ModelsChartPreferences(props: ModelsChartPreferencesProps) {
} }
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog
<DialogTrigger render={<Button variant='outline' size='sm' />}> open={open}
<Settings2 className='mr-2 h-4 w-4' /> onOpenChange={handleOpenChange}
{t('Preferences')} trigger={
</DialogTrigger> <Button variant='outline' size='sm'>
<DialogContent className='sm:max-w-md'> <Settings2 className='mr-2 h-4 w-4' />
<DialogHeader> {t('Preferences')}
<DialogTitle>{t('Dashboard Preferences')}</DialogTitle> </Button>
<DialogDescription> }
{t( title={t('Model Analytics Defaults')}
'Choose the default charts, range, and time granularity for model analytics.' description={t('Set default ranges and charts for model analytics.')}
)} contentClassName='sm:max-w-md'
</DialogDescription> contentHeight='auto'
</DialogHeader> bodyClassName='grid gap-3'
footer={
<div className='grid gap-4 py-2'> <Button onClick={handleSave} type='button'>
<div className='grid gap-2'> <Save className='mr-2 h-4 w-4' />
<Label htmlFor='default-time-range'>{t('Default range')}</Label> {t('Save Preferences')}
<Select </Button>
items={[ }
...TIME_RANGE_PRESETS.map((option) => ({ >
value: String(option.days), <div className='grid gap-1.5'>
label: t(option.label), <Label htmlFor='default-time-range'>{t('Default range')}</Label>
})), <Select
]} items={[
value={String(draft.defaultTimeRangeDays)} ...TIME_RANGE_PRESETS.map((option) => ({
onValueChange={(value) => value: String(option.days),
setDraft((prev) => ({ label: t(option.label),
...prev, })),
defaultTimeRangeDays: Number(value), ]}
})) value={String(draft.defaultTimeRangeDays)}
} onValueChange={(value) =>
> setDraft((prev) => ({
<SelectTrigger id='default-time-range'> ...prev,
<SelectValue placeholder={t('Select default range')} /> defaultTimeRangeDays: Number(value),
</SelectTrigger> }))
<SelectContent alignItemWithTrigger={false}> }
<SelectGroup> >
{TIME_RANGE_PRESETS.map((option) => ( <SelectTrigger id='default-time-range'>
<SelectItem key={option.days} value={String(option.days)}> <SelectValue placeholder={t('Select default range')} />
{t(option.label)} </SelectTrigger>
</SelectItem> <SelectContent alignItemWithTrigger={false}>
))} <SelectGroup>
</SelectGroup> {TIME_RANGE_PRESETS.map((option) => (
</SelectContent> <SelectItem key={option.days} value={String(option.days)}>
</Select> {t(option.label)}
</div> </SelectItem>
))}
<div className='grid gap-2'> </SelectGroup>
<Label htmlFor='default-time-granularity'> </SelectContent>
{t('Default time granularity')} </Select>
</Label> </div>
<Select <div className='grid gap-1.5'>
items={[ <Label htmlFor='default-time-granularity'>
...TIME_GRANULARITY_OPTIONS.map((option) => ({ {t('Default time granularity')}
value: option.value, </Label>
label: t(option.label), <Select
})), items={[
]} ...TIME_GRANULARITY_OPTIONS.map((option) => ({
value={draft.defaultTimeGranularity} value: option.value,
onValueChange={(value) => label: t(option.label),
setDraft((prev) => ({ })),
...prev, ]}
defaultTimeGranularity: value as TimeGranularity, value={draft.defaultTimeGranularity}
})) onValueChange={(value) =>
} setDraft((prev) => ({
> ...prev,
<SelectTrigger id='default-time-granularity'> defaultTimeGranularity: value as TimeGranularity,
<SelectValue placeholder={t('Select time granularity')} /> }))
</SelectTrigger> }
<SelectContent alignItemWithTrigger={false}> >
<SelectGroup> <SelectTrigger id='default-time-granularity'>
{TIME_GRANULARITY_OPTIONS.map((option) => ( <SelectValue placeholder={t('Select time granularity')} />
<SelectItem key={option.value} value={option.value}> </SelectTrigger>
{t(option.label)} <SelectContent alignItemWithTrigger={false}>
</SelectItem> <SelectGroup>
))} {TIME_GRANULARITY_OPTIONS.map((option) => (
</SelectGroup> <SelectItem key={option.value} value={option.value}>
</SelectContent> {t(option.label)}
</Select> </SelectItem>
</div> ))}
</SelectGroup>
<div className='grid gap-2'> </SelectContent>
<Label htmlFor='consumption-distribution-chart'> </Select>
{t('Default consumption chart')} </div>
</Label> <div className='grid gap-1.5'>
<Select <Label htmlFor='consumption-distribution-chart'>
items={[ {t('Default consumption chart')}
...CONSUMPTION_DISTRIBUTION_CHART_OPTIONS.map((option) => ({ </Label>
value: option.value, <Select
label: t(option.labelKey), items={[
})), ...CONSUMPTION_DISTRIBUTION_CHART_OPTIONS.map((option) => ({
]} value: option.value,
value={draft.consumptionDistributionChart} label: t(option.labelKey),
onValueChange={(value) => })),
setDraft((prev) => ({ ]}
...prev, value={draft.consumptionDistributionChart}
consumptionDistributionChart: onValueChange={(value) =>
value as ConsumptionDistributionChartType, setDraft((prev) => ({
})) ...prev,
} consumptionDistributionChart:
> value as ConsumptionDistributionChartType,
<SelectTrigger id='consumption-distribution-chart'> }))
<SelectValue placeholder={t('Select default chart')} /> }
</SelectTrigger> >
<SelectContent alignItemWithTrigger={false}> <SelectTrigger id='consumption-distribution-chart'>
<SelectGroup> <SelectValue placeholder={t('Select default chart')} />
{CONSUMPTION_DISTRIBUTION_CHART_OPTIONS.map((option) => ( </SelectTrigger>
<SelectItem key={option.value} value={option.value}> <SelectContent alignItemWithTrigger={false}>
{t(option.labelKey)} <SelectGroup>
</SelectItem> {CONSUMPTION_DISTRIBUTION_CHART_OPTIONS.map((option) => (
))} <SelectItem key={option.value} value={option.value}>
</SelectGroup> {t(option.labelKey)}
</SelectContent> </SelectItem>
</Select> ))}
</div> </SelectGroup>
</SelectContent>
<div className='grid gap-2'> </Select>
<Label htmlFor='model-analytics-chart'> </div>
{t('Default model call chart')} <div className='grid gap-1.5'>
</Label> <Label htmlFor='model-analytics-chart'>
<Select {t('Default model call chart')}
items={[ </Label>
...MODEL_ANALYTICS_CHART_OPTIONS.map((option) => ({ <Select
value: option.value, items={[
label: t(option.labelKey), ...MODEL_ANALYTICS_CHART_OPTIONS.map((option) => ({
})), value: option.value,
]} label: t(option.labelKey),
value={draft.modelAnalyticsChart} })),
onValueChange={(value) => ]}
setDraft((prev) => ({ value={draft.modelAnalyticsChart}
...prev, onValueChange={(value) =>
modelAnalyticsChart: value as ModelAnalyticsChartTab, setDraft((prev) => ({
})) ...prev,
} modelAnalyticsChart: value as ModelAnalyticsChartTab,
> }))
<SelectTrigger id='model-analytics-chart'> }
<SelectValue placeholder={t('Select default chart')} /> >
</SelectTrigger> <SelectTrigger id='model-analytics-chart'>
<SelectContent alignItemWithTrigger={false}> <SelectValue placeholder={t('Select default chart')} />
<SelectGroup> </SelectTrigger>
{MODEL_ANALYTICS_CHART_OPTIONS.map((option) => ( <SelectContent alignItemWithTrigger={false}>
<SelectItem key={option.value} value={option.value}> <SelectGroup>
{t(option.labelKey)} {MODEL_ANALYTICS_CHART_OPTIONS.map((option) => (
</SelectItem> <SelectItem key={option.value} value={option.value}>
))} {t(option.labelKey)}
</SelectGroup> </SelectItem>
</SelectContent> ))}
</Select> </SelectGroup>
</div> </SelectContent>
</div> </Select>
</div>
<DialogFooter>
<Button onClick={handleSave} type='button'>
<Save className='mr-2 h-4 w-4' />
{t('Save Preferences')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog> </Dialog>
) )
} }
@@ -23,15 +23,6 @@ import { useAuthStore } from '@/stores/auth-store'
import { getRollingDateRange, type TimeGranularity } from '@/lib/time' import { getRollingDateRange, type TimeGranularity } from '@/lib/time'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' 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 { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
@@ -44,6 +35,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { DateTimePicker } from '@/components/datetime-picker' import { DateTimePicker } from '@/components/datetime-picker'
import { Dialog } from '@/components/dialog'
import { import {
TIME_GRANULARITY_OPTIONS, TIME_GRANULARITY_OPTIONS,
TIME_RANGE_PRESETS, TIME_RANGE_PRESETS,
@@ -144,129 +136,22 @@ export function ModelsFilter(props: ModelsFilterProps) {
} }
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog
<DialogTrigger render={<Button variant='outline' size='sm' />}> open={open}
<Filter className='mr-2 h-4 w-4' /> onOpenChange={handleOpenChange}
{t('Filter')} trigger={
</DialogTrigger> <Button variant='outline' size='sm'>
<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'> <Filter className='mr-2 h-4 w-4' />
<DialogHeader> {t('Filter')}
<DialogTitle>{t('Filter Dashboard Models')}</DialogTitle> </Button>
<DialogDescription> }
{t( title={t('Model Analytics Filters')}
'Set filters to customize your dashboard statistics and charts.' 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'
</DialogDescription> contentHeight='min(48vh, 460px)'
</DialogHeader> footerClassName='grid grid-cols-2 gap-2 sm:flex'
footer={
<ScrollArea className='flex-1 pr-3 sm:pr-4'> <>
<div className='grid gap-3 py-3 sm:gap-4 sm:py-4'>
{/* Quick time range selection */}
<div className='grid gap-2'>
<Label className='flex items-center gap-2'>
<Calendar className='h-4 w-4' />
{t('Quick Range')}
</Label>
<div className='grid grid-cols-2 gap-2 sm:flex'>
{TIME_RANGE_PRESETS.map((range) => (
<Button
key={range.days}
type='button'
size='sm'
variant={
selectedRange === range.days ? 'default' : 'outline'
}
onClick={() => handleQuickRange(range.days)}
className={cn(
'flex-1',
selectedRange === range.days &&
'ring-ring ring-2 ring-offset-2'
)}
>
{t(range.label)}
</Button>
))}
</div>
</div>
<SectionDivider label={t('Custom Time Range')} />
{/* Custom time range */}
<div className='grid gap-3 sm:gap-4'>
<div className='grid gap-2'>
<Label htmlFor='start_timestamp'>{t('Start Time')}</Label>
<DateTimePicker
value={filters.start_timestamp}
onChange={(date) =>
handleChange('start_timestamp', date || undefined)
}
placeholder={t('Select start time')}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='end_timestamp'>{t('End Time')}</Label>
<DateTimePicker
value={filters.end_timestamp}
onChange={(date) =>
handleChange('end_timestamp', date || undefined)
}
placeholder={t('Select end time')}
/>
</div>
</div>
<SectionDivider label={t('Chart Settings')} />
<div className='grid gap-2'>
<Label htmlFor='time_granularity'>{t('Time Granularity')}</Label>
<Select
items={[
...TIME_GRANULARITY_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
})),
]}
value={filters.time_granularity}
onValueChange={(value) =>
handleChange('time_granularity', value as TimeGranularity)
}
>
<SelectTrigger>
<SelectValue placeholder={t('Select time granularity')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{TIME_GRANULARITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
{/* Admin-only fields */}
{isAdmin && (
<>
<SectionDivider label={t('Admin Only')} />
<div className='grid gap-2'>
<Label htmlFor='username'>{t('Username')}</Label>
<Input
id='username'
placeholder={t('Filter by username')}
value={filters.username}
onChange={(e) => handleChange('username', e.target.value)}
/>
</div>
</>
)}
</div>
</ScrollArea>
<DialogFooter className='grid grid-cols-2 gap-2 sm:flex'>
<Button onClick={handleReset} variant='outline' type='button'> <Button onClick={handleReset} variant='outline' type='button'>
<RotateCcw className='mr-2 h-4 w-4' /> <RotateCcw className='mr-2 h-4 w-4' />
{t('Reset')} {t('Reset')}
@@ -275,8 +160,113 @@ export function ModelsFilter(props: ModelsFilterProps) {
<Search className='mr-2 h-4 w-4' /> <Search className='mr-2 h-4 w-4' />
{t('Apply Filters')} {t('Apply Filters')}
</Button> </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> </Dialog>
) )
} }
@@ -18,15 +18,9 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { formatDateTimeObject } from '@/lib/time' import { formatDateTimeObject } from '@/lib/time'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Markdown } from '@/components/ui/markdown' import { Markdown } from '@/components/ui/markdown'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Dialog } from '@/components/dialog'
interface AnnouncementDetailModalProps { interface AnnouncementDetailModalProps {
open: boolean open: boolean
@@ -47,38 +41,39 @@ export function AnnouncementDetailModal({
}: AnnouncementDetailModalProps) { }: AnnouncementDetailModalProps) {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog
<DialogContent className='sm:max-w-lg'> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle>{t('Announcement Details')}</DialogTitle> title={t('Announcement Details')}
{announcement?.publishDate && ( description={
<DialogDescription> announcement?.publishDate
{t('Published:')}{' '} ? `${t('Published:')} ${formatDateTimeObject(new Date(announcement.publishDate))}`
{formatDateTimeObject(new Date(announcement.publishDate))} : undefined
</DialogDescription> }
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> {announcement?.extra && (
<ScrollArea className='max-h-[60vh] pr-4'> <div>
<div className='space-y-4'> <h4 className='mb-2 font-medium'>
{announcement?.content && ( {t('Additional Information')}
<div> </h4>
<h4 className='mb-2 font-medium'>{t('Content')}</h4> <Markdown className='text-muted-foreground'>
<Markdown>{announcement.content}</Markdown> {announcement.extra}
</div> </Markdown>
)} </div>
{announcement?.extra && ( )}
<div> </div>
<h4 className='mb-2 font-medium'> </ScrollArea>
{t('Additional Information')}
</h4>
<Markdown className='text-muted-foreground'>
{announcement.extra}
</Markdown>
</div>
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog> </Dialog>
) )
} }
@@ -76,18 +76,18 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
}, [resolvedFullKey, resolveRealKey, apiKey.id, markKeyCopied, t]) }, [resolvedFullKey, resolveRealKey, apiKey.id, markKeyCopied, t])
return ( return (
<div className='flex items-center'> <div className='flex max-w-full min-w-0 items-center'>
<Popover open={popoverOpen} onOpenChange={handlePopoverOpen}> <Popover open={popoverOpen} onOpenChange={handlePopoverOpen}>
<PopoverTrigger <PopoverTrigger
render={ render={
<Button <Button
variant='ghost' variant='ghost'
size='sm' 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> </PopoverTrigger>
<PopoverContent <PopoverContent
className='w-auto max-w-[min(90vw,28rem)]' className='w-auto max-w-[min(90vw,28rem)]'
@@ -92,6 +92,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
), ),
enableSorting: false, enableSorting: false,
enableHiding: false, enableHiding: false,
size: 40,
meta: { label: t('Select') }, meta: { label: t('Select') },
}, },
{ {
@@ -104,6 +105,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
{row.getValue('name')} {row.getValue('name')}
</div> </div>
), ),
size: 180,
meta: { label: t('Name'), mobileTitle: true }, 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))), filterFn: (row, id, value) => value.includes(String(row.getValue(id))),
size: 120,
meta: { label: t('Status'), mobileBadge: true }, meta: { label: t('Status'), mobileBadge: true },
}, },
{ {
@@ -131,6 +134,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
header: t('API Key'), header: t('API Key'),
cell: ({ row }) => <ApiKeyCell apiKey={row.original} />, cell: ({ row }) => <ApiKeyCell apiKey={row.original} />,
enableSorting: false, enableSorting: false,
size: 260,
meta: { label: t('API Key') }, meta: { label: t('API Key') },
}, },
{ {
@@ -189,6 +193,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
</Tooltip> </Tooltip>
) )
}, },
size: 170,
meta: { label: t('Quota') }, meta: { label: t('Quota') },
}, },
{ {
@@ -230,6 +235,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
} }
return <GroupBadge group={group} ratio={ratio} /> return <GroupBadge group={group} ratio={ratio} />
}, },
size: 160,
meta: { label: t('Group'), mobileHidden: true }, meta: { label: t('Group'), mobileHidden: true },
}, },
{ {
@@ -240,6 +246,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
), ),
cell: ({ row }) => <ModelLimitsCell apiKey={row.original} />, cell: ({ row }) => <ModelLimitsCell apiKey={row.original} />,
enableSorting: false, enableSorting: false,
size: 160,
meta: { label: t('Models'), mobileHidden: true }, meta: { label: t('Models'), mobileHidden: true },
}, },
{ {
@@ -250,6 +257,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
), ),
cell: ({ row }) => <IpRestrictionsCell apiKey={row.original} />, cell: ({ row }) => <IpRestrictionsCell apiKey={row.original} />,
enableSorting: false, enableSorting: false,
size: 160,
meta: { label: t('IP Restriction'), mobileHidden: true }, meta: { label: t('IP Restriction'), mobileHidden: true },
}, },
{ {
@@ -258,10 +266,11 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
<DataTableColumnHeader column={column} title={t('Created')} /> <DataTableColumnHeader column={column} title={t('Created')} />
), ),
cell: ({ row }) => ( 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'))} {formatTimestampToDate(row.getValue('created_time'))}
</span> </span>
), ),
size: 180,
meta: { label: t('Created'), mobileHidden: true }, 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 text-xs'>-</span>
} }
return ( 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)} {formatTimestampToDate(accessedTime)}
</span> </span>
) )
}, },
size: 180,
meta: { label: t('Last Used'), mobileHidden: true }, meta: { label: t('Last Used'), mobileHidden: true },
}, },
{ {
@@ -302,7 +312,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
return ( return (
<span <span
className={cn( className={cn(
'font-mono text-xs tabular-nums', 'block truncate font-mono text-xs tabular-nums',
isExpired ? 'text-destructive' : 'text-muted-foreground' isExpired ? 'text-destructive' : 'text-muted-foreground'
)} )}
> >
@@ -310,6 +320,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
</span> </span>
) )
}, },
size: 180,
meta: { label: t('Expires'), mobileHidden: true }, meta: { label: t('Expires'), mobileHidden: true },
}, },
{ {
+20 -62
View File
@@ -16,21 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router' import { getRouteApi } from '@tanstack/react-router'
import { import { type Table as TanstackTable } from '@tanstack/react-table'
type SortingState,
type VisibilityState,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table'
import { useDebounce } from '@/hooks'
import { Database } from 'lucide-react' import { Database } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -50,6 +38,8 @@ import {
DISABLED_ROW_DESKTOP, DISABLED_ROW_DESKTOP,
DISABLED_ROW_MOBILE, DISABLED_ROW_MOBILE,
DataTablePage, DataTablePage,
useDebouncedColumnFilter,
useDataTable,
} from '@/components/data-table' } from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { getApiKeys, searchApiKeys } from '../api' import { getApiKeys, searchApiKeys } from '../api'
@@ -99,7 +89,7 @@ function ApiKeysMobileList({
table, table,
isLoading, isLoading,
}: { }: {
table: ReturnType<typeof useReactTable<ApiKey>> table: TanstackTable<ApiKey>
isLoading: boolean isLoading: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
@@ -192,9 +182,6 @@ export function ApiKeysTable() {
const { t } = useTranslation() const { t } = useTranslation()
const { refreshTrigger } = useApiKeys() const { refreshTrigger } = useApiKeys()
const columns = useApiKeysColumns() const columns = useApiKeysColumns()
const [rowSelection, setRowSelection] = useState({})
const [sorting, setSorting] = useState<SortingState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const { const {
globalFilter, globalFilter,
@@ -215,27 +202,15 @@ export function ApiKeysTable() {
], ],
}) })
const tokenFilterFromUrl = const {
(columnFilters.find((f) => f.id === '_tokenSearch')?.value as string) || '' value: tokenFilter,
const [tokenFilterInput, setTokenFilterInput] = useState(tokenFilterFromUrl) inputValue: tokenFilterInput,
const debouncedTokenFilter = useDebounce(tokenFilterInput, 500) setInputValue: setTokenFilterInput,
} = useDebouncedColumnFilter({
useEffect(() => { columnFilters,
setTokenFilterInput(tokenFilterFromUrl) columnId: '_tokenSearch',
}, [tokenFilterFromUrl]) onColumnFiltersChange,
})
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 shouldSearch = Boolean(globalFilter?.trim() || tokenFilter.trim()) const shouldSearch = Boolean(globalFilter?.trim() || tokenFilter.trim())
// Fetch data with React Query // Fetch data with React Query
@@ -284,40 +259,22 @@ export function ApiKeysTable() {
const apiKeys = data?.items || [] const apiKeys = data?.items || []
const table = useReactTable({ const { table } = useDataTable({
data: apiKeys, data: apiKeys,
columns, columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
globalFilter,
pagination,
},
enableRowSelection: true, enableRowSelection: true,
onRowSelectionChange: setRowSelection, columnFilters,
onSortingChange: setSorting, globalFilter,
onColumnVisibilityChange: setColumnVisibility, pagination,
globalFilterFn: () => true, globalFilterFn: () => true,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
onPaginationChange, onPaginationChange,
onGlobalFilterChange, onGlobalFilterChange,
onColumnFiltersChange, onColumnFiltersChange,
manualPagination: true, 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 ( return (
<DataTablePage <DataTablePage
table={table} table={table}
@@ -329,6 +286,7 @@ export function ApiKeysTable() {
'No API keys available. Create your first API key to get started.' 'No API keys available. Create your first API key to get started.'
)} )}
skeletonKeyPrefix='api-keys-skeleton' skeletonKeyPrefix='api-keys-skeleton'
applyHeaderSize
toolbarProps={{ toolbarProps={{
searchPlaceholder: t('Filter by name...'), searchPlaceholder: t('Filter by name...'),
additionalSearch: ( additionalSearch: (
@@ -23,15 +23,9 @@ import { toast } from 'sonner'
import { getUserModels } from '@/lib/api' import { getUserModels } from '@/lib/api'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { ComboboxInput } from '@/components/ui/combobox-input' import { ComboboxInput } from '@/components/ui/combobox-input'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Dialog } from '@/components/dialog'
const APP_CONFIGS = { const APP_CONFIGS = {
claude: { claude: {
@@ -151,76 +145,78 @@ export function CCSwitchDialog(props: Props) {
} }
return ( return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}> <Dialog
<DialogContent className='sm:max-w-md'> open={props.open}
<DialogHeader> onOpenChange={props.onOpenChange}
<DialogTitle>{t('Import to CC Switch')}</DialogTitle> title={t('Import to CC Switch')}
</DialogHeader> contentClassName='sm:max-w-md'
contentHeight='auto'
<div className='space-y-4'> bodyClassName='space-y-4'
<div className='space-y-2'> footer={
<Label>{t('Application')}</Label> <>
<RadioGroup
value={app}
onValueChange={handleAppChange}
className='flex gap-4'
>
{(
Object.entries(APP_CONFIGS) as [
AppType,
(typeof APP_CONFIGS)[AppType],
][]
).map(([key, cfg]) => (
<div key={key} className='flex items-center gap-2'>
<RadioGroupItem value={key} id={`app-${key}`} />
<Label htmlFor={`app-${key}`} className='cursor-pointer'>
{cfg.label}
</Label>
</div>
))}
</RadioGroup>
</div>
<div className='space-y-2'>
<Label>{t('Name')}</Label>
<ComboboxInput
options={[]}
value={name}
onValueChange={setName}
placeholder={currentConfig.defaultName}
emptyText=''
allowCustomValue={true}
/>
</div>
{currentConfig.modelFields.map((field) => (
<div key={field.key} className='space-y-2'>
<Label>
{t(field.labelKey)}
{field.required && (
<span className='text-destructive ml-0.5'>*</span>
)}
</Label>
<ComboboxInput
options={modelOptions}
value={models[field.key] || ''}
onValueChange={(v) =>
setModels((prev) => ({ ...prev, [field.key]: v }))
}
placeholder={t('Select or enter model name')}
emptyText={t('No models found')}
/>
</div>
))}
</div>
<DialogFooter>
<Button variant='outline' onClick={() => props.onOpenChange(false)}> <Button variant='outline' onClick={() => props.onOpenChange(false)}>
{t('Cancel')} {t('Cancel')}
</Button> </Button>
<Button onClick={handleSubmit}>{t('Open CC Switch')}</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> </Dialog>
) )
} }
+1 -1
View File
@@ -27,7 +27,7 @@ export function ApiKeys() {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<ApiKeysProvider> <ApiKeysProvider>
<SectionPageLayout> <SectionPageLayout fixedContent>
<SectionPageLayout.Title>{t('API Keys')}</SectionPageLayout.Title> <SectionPageLayout.Title>{t('API Keys')}</SectionPageLayout.Title>
<SectionPageLayout.Actions> <SectionPageLayout.Actions>
<ApiKeysPrimaryButtons /> <ApiKeysPrimaryButtons />
@@ -24,20 +24,13 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { copyToClipboard } from '@/lib/copy-to-clipboard' import { copyToClipboard } from '@/lib/copy-to-clipboard'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table' import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import { import {
handleBatchEnableModels, handleBatchEnableModels,
handleBatchDisableModels, handleBatchDisableModels,
@@ -187,19 +180,17 @@ export function DataTableBulkActions<TData>({
</BulkActionsToolbar> </BulkActionsToolbar>
{/* Delete Confirmation Dialog */} {/* Delete Confirmation Dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}> <Dialog
<DialogContent> open={showDeleteConfirm}
<DialogHeader> onOpenChange={setShowDeleteConfirm}
<DialogTitle>{t('Delete Models?')}</DialogTitle> title={t('Delete Models?')}
<DialogDescription> description={t(
{t( 'Are you sure you want to delete {{count}} model(s)? This action cannot be undone.',
'Are you sure you want to delete {{count}} model(s)? This action cannot be undone.', { count: selectedIds.length }
{ count: selectedIds.length } )}
)} contentHeight='auto'
</DialogDescription> footer={
</DialogHeader> <>
<DialogFooter>
<Button <Button
variant='outline' variant='outline'
onClick={() => setShowDeleteConfirm(false)} onClick={() => setShowDeleteConfirm(false)}
@@ -209,8 +200,10 @@ export function DataTableBulkActions<TData>({
<Button variant='destructive' onClick={handleDeleteAll}> <Button variant='destructive' onClick={handleDeleteAll}>
{t('Delete')} {t('Delete')}
</Button> </Button>
</DialogFooter> </>
</DialogContent> }
>
{' '}
</Dialog> </Dialog>
</> </>
) )
@@ -21,7 +21,7 @@ import { Eye, Info, Pencil, Settings2, Timer, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { formatTimestampToDate } from '@/lib/format' import { formatTimestampToDate } from '@/lib/format'
import { Button } from '@/components/ui/button' 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 { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id' import { TableId } from '@/components/table-id'
import { getDeploymentStatusConfig } from '../constants' 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 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 { useQuery, useQueryClient } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router' import { getRouteApi } from '@tanstack/react-router'
import {
getCoreRowModel,
useReactTable,
type VisibilityState,
} from '@tanstack/react-table'
import { useMediaQuery } from '@/hooks' import { useMediaQuery } from '@/hooks'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -38,7 +33,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } 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 { deleteDeployment, listDeployments, searchDeployments } from '../api'
import { getDeploymentStatusOptions } from '../constants' import { getDeploymentStatusOptions } from '../constants'
import { deploymentsQueryKeys } from '../lib' import { deploymentsQueryKeys } from '../lib'
@@ -167,8 +162,6 @@ export function DeploymentsTable() {
} }
} }
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const columns = useDeploymentsColumns({ const columns = useDeploymentsColumns({
onViewLogs: (id) => { onViewLogs: (id) => {
setLogsDeploymentId(id) setLogsDeploymentId(id)
@@ -197,30 +190,22 @@ export function DeploymentsTable() {
}, },
}) })
const table = useReactTable({ const { table } = useDataTable({
data: deployments, data: deployments,
columns, columns,
pageCount: Math.ceil(totalCount / pagination.pageSize), totalCount,
state: { columnFilters,
columnFilters, pagination,
columnVisibility, globalFilter,
pagination,
globalFilter,
},
onColumnFiltersChange, onColumnFiltersChange,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange, onPaginationChange,
onGlobalFilterChange, onGlobalFilterChange,
getCoreRowModel: getCoreRowModel(),
manualPagination: true, manualPagination: true,
manualFiltering: true, manualFiltering: true,
withSortedRowModel: false,
ensurePageInRange,
}) })
const pageCount = table.getPageCount()
useEffect(() => {
ensurePageInRange(pageCount)
}, [ensurePageInRange, pageCount])
const statusFilterOptions = useMemo(() => { const statusFilterOptions = useMemo(() => {
return [...getDeploymentStatusOptions(t)].map((opt) => ({ return [...getDeploymentStatusOptions(t)].map((opt) => ({
label: opt.label, label: opt.label,
@@ -17,14 +17,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Dialog } from '@/components/dialog'
type DescriptionDialogProps = { type DescriptionDialogProps = {
open: boolean open: boolean
@@ -41,21 +35,22 @@ export function DescriptionDialog({
}: DescriptionDialogProps) { }: DescriptionDialogProps) {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog
<DialogContent className='max-w-2xl'> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle>{modelName}</DialogTitle> title={modelName}
<DialogDescription>{t('Model Description')}</DialogDescription> description={t('Model Description')}
</DialogHeader> contentClassName='max-w-2xl'
contentHeight='auto'
<ScrollArea className='max-h-96'> bodyClassName='space-y-4'
<div className='space-y-2 pr-4'> >
<p className='text-foreground text-sm leading-relaxed break-words whitespace-pre-wrap'> <ScrollArea className='max-h-96'>
{description} <div className='space-y-2 pr-4'>
</p> <p className='text-foreground text-sm leading-relaxed break-words whitespace-pre-wrap'>
</div> {description}
</ScrollArea> </p>
</DialogContent> </div>
</ScrollArea>
</Dialog> </Dialog>
) )
} }
@@ -22,15 +22,9 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Dialog } from '@/components/dialog'
import { estimatePrice, extendDeployment, getDeployment } from '../../api' import { estimatePrice, extendDeployment, getDeployment } from '../../api'
import { deploymentsQueryKeys } from '../../lib' import { deploymentsQueryKeys } from '../../lib'
@@ -164,62 +158,16 @@ export function ExtendDeploymentDialog({
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog
<DialogContent className='sm:max-w-lg'> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle>{t('Extend deployment')}</DialogTitle> title={t('Extend deployment')}
</DialogHeader> contentClassName='sm:max-w-lg'
footerClassName='mt-4'
{isLoadingDetails ? ( contentHeight='auto'
<div className='flex items-center justify-center py-10'> bodyClassName='space-y-4'
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' /> footer={
</div> <>
) : (
<div className='space-y-4'>
<div className='text-muted-foreground text-sm'>
{t('Deployment ID')}:{' '}
<span className='font-mono'>{deploymentId}</span>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>{t('Duration (hours)')}</div>
<Input
type='number'
min={1}
value={hours}
onChange={(e) => setHours(toInt(e.target.value, 1))}
/>
<div className='text-muted-foreground text-xs'>
{t('This will extend the deployment by the specified hours.')}
</div>
</div>
<Separator />
<div className='space-y-1'>
<div className='text-sm font-medium'>{t('Estimated cost')}</div>
<div className='text-muted-foreground text-sm'>
{isLoadingPrice || isFetchingPrice ? (
<span className='inline-flex items-center gap-2'>
<Loader2 className='h-4 w-4 animate-spin' />
{t('Calculating...')}
</span>
) : priceParams ? (
priceSummary || t('Not available')
) : (
t('Not available')
)}
</div>
{!priceParams ? (
<div className='text-muted-foreground text-xs'>
{t('Unable to estimate price for this deployment.')}
</div>
) : null}
</div>
</div>
)}
<DialogFooter className='mt-4'>
<Button variant='outline' onClick={() => onOpenChange(false)}> <Button variant='outline' onClick={() => onOpenChange(false)}>
{t('Cancel')} {t('Cancel')}
</Button> </Button>
@@ -229,8 +177,57 @@ export function ExtendDeploymentDialog({
) : null} ) : null}
{t('Extend')} {t('Extend')}
</Button> </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> </Dialog>
) )
} }
@@ -22,13 +22,6 @@ import { ChevronLeft, ChevronRight, Loader2, Plus, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useIsMobile } from '@/hooks/use-mobile' import { useIsMobile } from '@/hooks/use-mobile'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { import {
Empty, Empty,
EmptyDescription, EmptyDescription,
@@ -37,6 +30,7 @@ import {
EmptyTitle, EmptyTitle,
} from '@/components/ui/empty' } from '@/components/ui/empty'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { getMissingModels } from '../../api' import { getMissingModels } from '../../api'
import { DEFAULT_PAGE_SIZE } from '../../constants' import { DEFAULT_PAGE_SIZE } from '../../constants'
@@ -115,133 +109,130 @@ export function MissingModelsDialog({
const showPagination = totalItems > pageSize const showPagination = totalItems > pageSize
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog
<DialogContent open={open}
className='flex max-h-[85vh] max-w-2xl flex-col gap-3 p-4' onOpenChange={onOpenChange}
initialFocus={!isMobile} title={t('Missing Models')}
> description={t(
<DialogHeader className='flex-shrink-0 text-start'> 'Models that are being used but not configured in the system'
<DialogTitle>{t('Missing Models')}</DialogTitle> )}
<DialogDescription> contentClassName='flex max-h-[85vh] max-w-2xl flex-col gap-3 p-4'
{t('Models that are being used but not configured in the system')} headerClassName='flex-shrink-0 text-start'
</DialogDescription> contentHeight='min(74vh, 760px)'
</DialogHeader> 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 ? ( {filteredModels.length === 0 ? (
<div className='flex items-center justify-center py-12'> <Empty className='border'>
<Loader2 className='h-8 w-8 animate-spin' /> <EmptyHeader>
</div> <EmptyMedia variant='icon'>
) : missingModels.length === 0 ? ( <Search className='h-5 w-5' />
<div className='text-muted-foreground py-12 text-center'> </EmptyMedia>
<p>{t('No missing models found.')}</p> <EmptyTitle>{t('No matches found')}</EmptyTitle>
<p className='text-sm'> <EmptyDescription>
{t('All models in use are properly configured.')} {t('Try adjusting your search to locate a missing model.')}
</p> </EmptyDescription>
</div> </EmptyHeader>
) : ( </Empty>
<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='flex-shrink-0 rounded-lg border'>
<div className='text-muted-foreground text-sm whitespace-nowrap'> <div className='divide-y'>
{t('Showing')} {displayStart}-{displayEnd} {t('of')}{' '} {paginatedModels.map((modelName) => (
{totalItems} <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>
<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' /> <div className='bg-muted/40 flex items-center justify-between border-t px-3 py-2 text-sm'>
<Input <div className='text-muted-foreground text-sm'>
value={searchTerm} {t('Page {{current}} of {{total}}', {
onChange={(event) => { current: currentPage,
setSearchTerm(event.target.value) total: totalPages,
setCurrentPage(1) })}
}} </div>
placeholder={t('Search models...')} {showPagination && (
className='pl-9' <div className='flex items-center gap-2'>
aria-label={t('Search missing models')} <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> </div>
)}
{filteredModels.length === 0 ? ( </div>
<Empty className='border'> )}
<EmptyHeader>
<EmptyMedia variant='icon'>
<Search className='h-5 w-5' />
</EmptyMedia>
<EmptyTitle>{t('No matches found')}</EmptyTitle>
<EmptyDescription>
{t('Try adjusting your search to locate a missing model.')}
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className='flex-shrink-0 rounded-lg border'>
<div className='divide-y'>
{paginatedModels.map((modelName) => (
<div
key={modelName}
className='flex items-center justify-between gap-3 p-3'
>
<div className='min-w-0 flex-1'>
<StatusBadge
label={modelName}
variant='neutral'
copyText={modelName}
/>
</div>
<Button
size='sm'
className='flex-shrink-0 gap-1'
onClick={() => handleConfigureModel(modelName)}
>
<Plus className='h-4 w-4' />
Configure
</Button>
</div>
))}
</div>
<div className='bg-muted/40 flex items-center justify-between border-t px-3 py-2 text-sm'>
<div className='text-muted-foreground text-sm'>
{t('Page {{current}} of {{total}}', {
current: currentPage,
total: totalPages,
})}
</div>
{showPagination && (
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='icon'
className='h-8 w-8'
onClick={() =>
setCurrentPage((prev) => Math.max(1, prev - 1))
}
disabled={currentPage === 1}
aria-label={t('Previous page')}
>
<ChevronLeft className='h-4 w-4' />
</Button>
<Button
variant='outline'
size='icon'
className='h-8 w-8'
onClick={() =>
setCurrentPage((prev) =>
Math.min(totalPages, prev + 1)
)
}
disabled={currentPage === totalPages}
aria-label={t('Next page')}
>
<ChevronRight className='h-4 w-4' />
</Button>
</div>
)}
</div>
</div>
)}
</div>
)}
</DialogContent>
</Dialog> </Dialog>
) )
} }
@@ -25,7 +25,6 @@ import {
Plus, Plus,
RefreshCcw, RefreshCcw,
Trash2, Trash2,
X,
} from 'lucide-react' } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -40,14 +39,6 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { import {
Empty, Empty,
EmptyDescription, EmptyDescription,
@@ -55,15 +46,9 @@ import {
EmptyMedia, EmptyMedia,
EmptyTitle, EmptyTitle,
} from '@/components/ui/empty' } from '@/components/ui/empty'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ConfirmDialog } from '@/components/confirm-dialog' import { ConfirmDialog } from '@/components/confirm-dialog'
import { StaticDataTable } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id' import { TableId } from '@/components/table-id'
import { deletePrefillGroup, getPrefillGroups } from '../../api' import { deletePrefillGroup, getPrefillGroups } from '../../api'
@@ -172,335 +157,299 @@ export function PrefillGroupManagementDialog({
return ( return (
<> <>
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog
<DialogContent open={open}
showCloseButton={false} onOpenChange={onOpenChange}
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' title={
style={{ maxWidth: 'min(100vw, 64rem)' }} <>
> <Layers3 className='text-foreground/80 h-5 w-5' />
<div {t('Prefill Group Management')}
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' description={t(
)} 'Create reusable bundles of models, tags, endpoints, and user groups to speed up configuration elsewhere in the console.'
> )}
<div contentClassName={cn(
className={cn( 'w-[calc(100vw-2rem)] sm:max-w-[52rem]',
'relative flex flex-col gap-3 border-b px-4 py-4 sm:px-6 sm:py-5', isMobile && 'max-w-none rounded-none'
isMobile && 'pt-[calc(env(safe-area-inset-top,0px)+1rem)]' )}
)} 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'> {isFetching ? (
<DialogTitle className='flex flex-wrap items-center gap-2 text-xl'> <Loader2 className='mr-2 h-4 w-4 animate-spin' />
<Layers3 className='text-foreground/80 h-5 w-5' /> ) : (
{t('Prefill Group Management')} <RefreshCcw className='mr-2 h-4 w-4' />
</DialogTitle> )}
<DialogDescription className='text-base leading-relaxed sm:text-sm'> {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( {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> </EmptyDescription>
</DialogHeader> </EmptyHeader>
<EmptyDescription>
<DialogClose {t(
render={ 'Prefill groups help you keep complex configurations in sync.'
<Button )}
variant='ghost' </EmptyDescription>
size='icon' </Empty>
className='text-muted-foreground hover:text-foreground absolute top-4 right-4 border border-transparent sm:top-5 sm:right-6' ) : isMobile ? (
/> <div className='space-y-3'>
} {normalizedGroups.map(({ group, meta, parsedItems }) => (
> <Card key={group.id} className='border-border/60'>
<span className='sr-only'>{t('Close dialog')}</span> <CardHeader className='flex flex-row items-start justify-between gap-4'>
<X className='h-4 w-4' /> <div className='space-y-2'>
</DialogClose> <CardTitle className='flex flex-wrap items-center gap-2'>
</div> {group.name}
<StatusBadge
<div className='flex flex-wrap items-center gap-3 border-b px-4 py-3 text-sm sm:px-6'> variant={meta.badge}
<div className='flex flex-wrap items-center gap-2'> size='sm'
<Button size='sm' onClick={onCreateGroup}> copyable={false}
<Plus className='mr-2 h-4 w-4' /> >
{t('New Group')} {meta.label}
</Button> <span className='text-muted-foreground/30'>·</span>
<Button <span className='text-muted-foreground font-mono'>
size='sm' #{group.id}
variant='ghost' </span>
onClick={() => refetchGroups()} </StatusBadge>
disabled={isFetching} </CardTitle>
> {group.description ? (
{isFetching ? ( <CardDescription className='line-clamp-2'>
<Loader2 className='mr-2 h-4 w-4 animate-spin' /> {group.description}
) : ( </CardDescription>
<RefreshCcw className='mr-2 h-4 w-4' /> ) : (
)} <CardDescription className='text-muted-foreground italic'>
{t('Refresh')} No description provided
</Button> </CardDescription>
</div> )}
<StatusBadge
label={`${groups.length} group${groups.length === 1 ? '' : 's'}`}
variant='neutral'
copyable={false}
/>
</div>
<div
className={cn(
'flex flex-1 flex-col overflow-hidden px-4 py-4 sm:px-6 sm:py-6',
isMobile && 'pb-[calc(env(safe-area-inset-bottom,0px)+1.5rem)]'
)}
>
<div className='flex-1 overflow-y-auto'>
<div className='flex flex-col gap-4'>
{error && (
<Alert variant='destructive'>
<AlertTitle>{t('Unable to load groups')}</AlertTitle>
<AlertDescription>
{(error as Error).message ||
'Please retry or refresh the page.'}
</AlertDescription>
</Alert>
)}
{isLoading ? (
<div className='flex flex-col items-center justify-center gap-2 py-16 text-center'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
<p className='text-muted-foreground text-sm'>
{t('Fetching prefill groups...')}
</p>
</div> </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'> <div className='flex items-center gap-2'>
<Button <Button
size='icon' size='icon'
variant='outline' variant='outline'
onClick={() => onEditGroup(group)} onClick={() => onEditGroup(group)}
> >
<Pencil className='h-4 w-4' /> <Pencil className='h-4 w-4' />
<span className='sr-only'>Edit group</span> <span className='sr-only'>Edit group</span>
</Button> </Button>
<Button <Button
size='icon' size='icon'
variant='ghost' variant='ghost'
className='text-destructive hover:text-destructive' className='text-destructive hover:text-destructive'
onClick={() => handleDeleteClick(group)} onClick={() => handleDeleteClick(group)}
> >
<Trash2 className='h-4 w-4' /> <Trash2 className='h-4 w-4' />
<span className='sr-only'>Delete group</span> <span className='sr-only'>Delete group</span>
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className='space-y-3'> <CardContent className='space-y-3'>
<div className='text-muted-foreground flex flex-wrap items-center gap-2 text-xs font-medium tracking-wide uppercase'> <div className='text-muted-foreground flex flex-wrap items-center gap-2 text-xs font-medium tracking-wide uppercase'>
<span>Items</span> <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 <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' variant='neutral'
size='sm' size='sm'
copyable={false} 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> ) : (
))} <p className='text-muted-foreground text-sm'>
</div> {group.type === 'endpoint'
) : ( ? 'No endpoint mappings configured.'
<div className='rounded-md border'> : 'No items configured yet.'}
<div className='w-full overflow-x-auto'> </p>
<Table className='min-w-[720px]'> )}
<TableHeader>
<TableRow>
<TableHead>{t('Group')}</TableHead>
<TableHead>{t('Type')}</TableHead>
<TableHead className='min-w-[280px]'>
{t('Items')}
</TableHead>
<TableHead className='w-[120px] text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{normalizedGroups.map(
({ group, meta, parsedItems }) => (
<TableRow key={group.id}>
<TableCell className='align-top whitespace-normal'>
<div className='flex flex-col gap-1'>
<div className='flex flex-wrap items-center gap-2'>
<span className='font-medium'>
{group.name}
</span>
<TableId value={group.id} />
</div>
{group.description ? (
<p className='text-muted-foreground text-xs'>
{group.description}
</p>
) : (
<p className='text-muted-foreground text-xs italic'>
No description provided
</p>
)}
</div>
</TableCell>
<TableCell className='align-top'>
<StatusBadge
label={meta.label}
variant={meta.badge}
size='sm'
copyable={false}
/>
</TableCell>
<TableCell className='align-top whitespace-normal'>
<div className='flex flex-wrap gap-2'>
{parsedItems.length > 0 ? (
<>
{parsedItems
.slice(0, 6)
.map((item) => (
<StatusBadge
key={item}
label={item}
autoColor={item}
size='sm'
/>
))}
{parsedItems.length > 6 && (
<StatusBadge
label={`+${parsedItems.length - 6} more`}
variant='neutral'
size='sm'
copyable={false}
/>
)}
</>
) : (
<p className='text-muted-foreground text-sm'>
{group.type === 'endpoint'
? 'No endpoint mappings configured.'
: 'No items configured yet.'}
</p>
)}
</div>
<div className='text-muted-foreground mt-2 text-xs font-medium tracking-wide uppercase'>
{parsedItems.length} item
{parsedItems.length === 1 ? '' : 's'}
</div>
</TableCell>
<TableCell className='align-top'>
<div className='flex justify-end gap-2'>
<Button
size='icon'
variant='outline'
onClick={() => onEditGroup(group)}
>
<Pencil className='h-4 w-4' />
<span className='sr-only'>
Edit group
</span>
</Button>
<Button
size='icon'
variant='ghost'
className='text-destructive hover:text-destructive'
onClick={() => handleDeleteClick(group)}
>
<Trash2 className='h-4 w-4' />
<span className='sr-only'>
Delete group
</span>
</Button>
</div>
</TableCell>
</TableRow>
)
)}
</TableBody>
</Table>
</div> </div>
<div 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> />
</div> )}
</DialogContent> </div>
</Dialog> </Dialog>
<ConfirmDialog <ConfirmDialog
@@ -22,14 +22,8 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog'
import { checkClusterNameAvailability, updateDeploymentName } from '../../api' import { checkClusterNameAvailability, updateDeploymentName } from '../../api'
import { deploymentsQueryKeys } from '../../lib' import { deploymentsQueryKeys } from '../../lib'
@@ -111,27 +105,16 @@ export function RenameDeploymentDialog({
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog
<DialogContent className='sm:max-w-lg'> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle>{t('Rename deployment')}</DialogTitle> title={t('Rename deployment')}
</DialogHeader> contentClassName='sm:max-w-lg'
footerClassName='mt-4'
<div className='space-y-2'> contentHeight='auto'
<div className='text-muted-foreground text-sm'> bodyClassName='space-y-4'
{t('Deployment ID')}:{' '} footer={
<span className='font-mono'>{deploymentId}</span> <>
</div>
<Input
placeholder={t('Enter a new name')}
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete='off'
/>
<div className='text-muted-foreground text-xs'>{helper}</div>
</div>
<DialogFooter className='mt-4'>
<Button variant='outline' onClick={() => onOpenChange(false)}> <Button variant='outline' onClick={() => onOpenChange(false)}>
{t('Cancel')} {t('Cancel')}
</Button> </Button>
@@ -141,8 +124,22 @@ export function RenameDeploymentDialog({
) : null} ) : null}
{t('Rename')} {t('Rename')}
</Button> </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> </Dialog>
) )
} }
@@ -24,16 +24,9 @@ import { toast } from 'sonner'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useIsMobile } from '@/hooks/use-mobile' import { useIsMobile } from '@/hooks/use-mobile'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { syncUpstream, previewUpstreamDiff } from '../../api' import { syncUpstream, previewUpstreamDiff } from '../../api'
import { getSyncLocaleOptions, getSyncSourceOptions } from '../../constants' import { getSyncLocaleOptions, getSyncSourceOptions } from '../../constants'
@@ -125,117 +118,16 @@ export function SyncWizardDialog({
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog
<DialogContent open={open}
className='flex max-h-[90vh] w-full flex-col gap-4 p-4 sm:max-w-2xl sm:p-6' onOpenChange={onOpenChange}
initialFocus={!isMobile} title={t('Sync Upstream Models')}
> description={t('Synchronize models and vendors from an upstream source')}
<DialogHeader className='flex-shrink-0 text-start'> initialFocus={!isMobile}
<DialogTitle>{t('Sync Upstream Models')}</DialogTitle> contentHeight='auto'
<DialogDescription> bodyClassName='flex flex-col gap-6'
{t('Synchronize models and vendors from an upstream source')} footer={
</DialogDescription> <>
</DialogHeader>
<div className='flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto'>
<div className='space-y-3'>
<div>
<Label className='text-base'>{t('Select Sync Source')}</Label>
<p className='text-muted-foreground text-sm'>
{t('Choose where to fetch upstream metadata.')}
</p>
</div>
<RadioGroup
value={source}
onValueChange={(value) => {
const selected = SYNC_SOURCE_OPTIONS.find(
(option) => option.value === value
)
if (!selected || selected.disabled) return
setSource(selected.value)
}}
className='grid gap-3 md:grid-cols-2'
>
{SYNC_SOURCE_OPTIONS.map((option) => {
const isActive = source === option.value
const isDisabled = option.disabled
return (
<Label
key={option.value}
htmlFor={`sync-source-${option.value}`}
className={cn(
'flex-col items-start gap-0 rounded-lg border p-4 font-normal transition-all',
isActive && 'border-primary ring-primary ring-1',
isDisabled
? 'cursor-not-allowed opacity-60'
: 'hover:border-primary/60 cursor-pointer'
)}
>
<div className='flex items-start gap-3'>
<RadioGroupItem
value={option.value}
id={`sync-source-${option.value}`}
disabled={isDisabled}
/>
<div className='space-y-1'>
<div className='flex items-center gap-2'>
<span className='font-medium'>{option.label}</span>
{option.value === 'official' && (
<StatusBadge
label='Default'
variant='neutral'
copyable={false}
/>
)}
</div>
<p className='text-muted-foreground text-sm'>
{option.description}
</p>
</div>
</div>
</Label>
)
})}
</RadioGroup>
</div>
<div className='space-y-2'>
<Label className='text-base'>{t('Select Language')}</Label>
<RadioGroup
value={locale}
onValueChange={(v) => setLocale(v as SyncLocale)}
className='grid gap-3 sm:grid-cols-3'
>
{SYNC_LOCALE_OPTIONS.map((option) => (
<div
key={option.value}
className='flex items-center space-x-2 rounded-lg border p-3'
>
<RadioGroupItem
value={option.value}
id={`locale-${option.value}`}
/>
<Label
htmlFor={`locale-${option.value}`}
className='cursor-pointer font-normal'
>
{option.label}
</Label>
</div>
))}
</RadioGroup>
</div>
<div className='bg-muted/50 rounded-lg border p-4'>
<p className='text-muted-foreground text-sm'>
{t(
'The sync will fetch missing models and vendors from the selected source. Existing records are updated only when you approve conflicts.'
)}
</p>
</div>
</div>
<DialogFooter className='flex-shrink-0 gap-2 sm:justify-end'>
<Button <Button
variant='outline' variant='outline'
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
@@ -246,10 +138,106 @@ export function SyncWizardDialog({
<Button onClick={handleSync} disabled={isSyncing}> <Button onClick={handleSync} disabled={isSyncing}>
{isSyncing && <Loader2 className='mr-2 h-4 w-4 animate-spin' />} {isSyncing && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
<RefreshCw className='mr-2 h-4 w-4' /> <RefreshCw className='mr-2 h-4 w-4' />
{isSyncing ? 'Syncing...' : 'Sync Now'} {isSyncing ? t('Syncing...') : t('Sync Now')}
</Button> </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> </Dialog>
) )
} }
@@ -30,13 +30,6 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { import {
Form, Form,
FormControl, FormControl,
@@ -47,6 +40,7 @@ import {
} from '@/components/ui/form' } from '@/components/ui/form'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { getDeployment, updateDeployment } from '../../api' import { getDeployment, updateDeployment } from '../../api'
import { deploymentsQueryKeys } from '../../lib' import { deploymentsQueryKeys } from '../../lib'
@@ -64,6 +58,8 @@ const schema = z.object({
type Values = z.input<typeof schema> type Values = z.input<typeof schema>
const UPDATE_CONFIG_FORM_ID = 'update-config-form'
function normalizeJsonObject(input?: string) { function normalizeJsonObject(input?: string) {
if (!input || !input.trim()) return undefined if (!input || !input.trim()) return undefined
const parsed = JSON.parse(input) const parsed = JSON.parse(input)
@@ -212,226 +208,228 @@ export function UpdateConfigDialog({
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog
<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'> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle>{title}</DialogTitle> title={title}
</DialogHeader> 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'
{isLoading ? ( bodyClassName='space-y-4'
<div className='flex items-center justify-center py-10'> footer={
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' /> isLoading ? null : (
</div> <>
) : ( <Button
<div className='max-h-[calc(100dvh-8.5rem)] overflow-y-auto py-2 pr-1 sm:max-h-[72vh]'> type='button'
<Form {...form}> variant='outline'
<form onClick={() => onOpenChange(false)}
onSubmit={form.handleSubmit(onSubmit)} >
autoComplete='off' {t('Cancel')}
className='space-y-4' </Button>
> <Button
<div className='grid gap-3 md:grid-cols-2 md:gap-4'> type='submit'
<FormField form={UPDATE_CONFIG_FORM_ID}
control={form.control} disabled={form.formState.isSubmitting}
name='image_url' >
render={({ field }) => ( {form.formState.isSubmitting ? (
<FormItem> <Loader2 className='mr-2 h-4 w-4 animate-spin' />
<FormLabel>{t('Image')}</FormLabel> ) : null}
<FormControl> {t('Update')}
<Input </Button>
placeholder='ollama/ollama:latest' </>
{...field} )
/> }
</FormControl> >
<FormMessage /> {isLoading ? (
</FormItem> <div className='flex items-center justify-center py-10'>
)} <Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
/> </div>
) : (
<FormField <div className='max-h-[calc(100dvh-8.5rem)] overflow-y-auto py-2 pr-1 sm:max-h-[72vh]'>
control={form.control} <Form {...form}>
name='traffic_port' <form
render={({ field }) => ( id={UPDATE_CONFIG_FORM_ID}
<FormItem> onSubmit={form.handleSubmit(onSubmit)}
<FormLabel>{t('Port')}</FormLabel> autoComplete='off'
<FormControl> className='space-y-4'
<Input >
type='number' <div className='grid gap-3 md:grid-cols-2 md:gap-4'>
min={1}
max={65535}
value={
typeof field.value === 'number' ||
typeof field.value === 'string'
? field.value
: ''
}
onChange={(e) => {
const v = e.target.value
field.onChange(v === '' ? undefined : Number(v))
}}
onBlur={field.onBlur}
name={field.name}
ref={field.ref}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid gap-3 md:grid-cols-2 md:gap-4'>
<FormField
control={form.control}
name='entrypoint'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Entrypoint (space separated)')}
</FormLabel>
<FormControl>
<Input placeholder='bash -lc' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='args'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Args (space separated)')}</FormLabel>
<FormControl>
<Input placeholder='--foo bar' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField <FormField
control={form.control} control={form.control}
name='command' name='image_url'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('Command')}</FormLabel> <FormLabel>{t('Image')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder='Optional' {...field} /> <Input placeholder='ollama/ollama:latest' {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<Collapsible className='rounded-md border p-3'> <FormField
<CollapsibleTrigger className='cursor-pointer text-sm'> control={form.control}
{t('Registry (optional)')} name='traffic_port'
</CollapsibleTrigger> render={({ field }) => (
<CollapsibleContent> <FormItem>
<div className='mt-3 grid gap-3 md:grid-cols-2 md:gap-4'> <FormLabel>{t('Port')}</FormLabel>
<FormField <FormControl>
control={form.control} <Input
name='registry_username' type='number'
render={({ field }) => ( min={1}
<FormItem> max={65535}
<FormLabel>{t('Registry username')}</FormLabel> value={
<FormControl> typeof field.value === 'number' ||
<Input autoComplete='off' {...field} /> typeof field.value === 'string'
</FormControl> ? field.value
<FormMessage /> : ''
</FormItem> }
)} onChange={(e) => {
/> const v = e.target.value
<FormField field.onChange(v === '' ? undefined : Number(v))
control={form.control} }}
name='registry_secret' onBlur={field.onBlur}
render={({ field }) => ( name={field.name}
<FormItem> ref={field.ref}
<FormLabel>{t('Registry secret')}</FormLabel> />
<FormControl> </FormControl>
<Input <FormMessage />
type='password' </FormItem>
autoComplete='off' )}
{...field} />
/> </div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</Collapsible>
<Collapsible className='rounded-md border p-3'> <div className='grid gap-3 md:grid-cols-2 md:gap-4'>
<CollapsibleTrigger className='cursor-pointer text-sm'> <FormField
{t('Environment variables')} control={form.control}
</CollapsibleTrigger> name='entrypoint'
<CollapsibleContent> render={({ field }) => (
<div className='mt-3 grid gap-3 md:grid-cols-2 md:gap-4'> <FormItem>
<FormField <FormLabel>{t('Entrypoint (space separated)')}</FormLabel>
control={form.control} <FormControl>
name='env_json' <Input placeholder='bash -lc' {...field} />
render={({ field }) => ( </FormControl>
<FormItem> <FormMessage />
<FormLabel>{t('Env (JSON object)')}</FormLabel> </FormItem>
<FormControl> )}
<Textarea />
className='min-h-40 font-mono text-xs'
placeholder='{"KEY":"VALUE"}'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='secret_env_json'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Secret env (JSON object)')}
</FormLabel>
<FormControl>
<Textarea
className='min-h-40 font-mono text-xs'
placeholder='{"SECRET":"VALUE"}'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</Collapsible>
<DialogFooter className='grid grid-cols-2 gap-2 pt-2 sm:flex'> <FormField
<Button control={form.control}
type='button' name='args'
variant='outline' render={({ field }) => (
onClick={() => onOpenChange(false)} <FormItem>
> <FormLabel>{t('Args (space separated)')}</FormLabel>
{t('Cancel')} <FormControl>
</Button> <Input placeholder='--foo bar' {...field} />
<Button type='submit' disabled={form.formState.isSubmitting}> </FormControl>
{form.formState.isSubmitting ? ( <FormMessage />
<Loader2 className='mr-2 h-4 w-4 animate-spin' /> </FormItem>
) : null} )}
{t('Update')} />
</Button> </div>
</DialogFooter>
</form> <FormField
</Form> control={form.control}
</div> name='command'
)} render={({ field }) => (
</DialogContent> <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> </Dialog>
) )
} }
@@ -18,13 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import { useEffect, useMemo, useState, useCallback } from 'react' import { useEffect, useMemo, useState, useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { import { type ColumnDef, type RowSelectionState } from '@tanstack/react-table'
flexRender,
getCoreRowModel,
useReactTable,
type ColumnDef,
type RowSelectionState,
} from '@tanstack/react-table'
import { import {
Search, Search,
Info, Info,
@@ -37,14 +31,6 @@ import { toast } from 'sonner'
import { useIsMobile } from '@/hooks/use-mobile' import { useIsMobile } from '@/hooks/use-mobile'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { import {
Popover, Popover,
@@ -59,14 +45,8 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { import { DataTableView, useDataTable } from '@/components/data-table'
Table, import { Dialog } from '@/components/dialog'
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { applyUpstreamOverwrite } from '../../api' import { applyUpstreamOverwrite } from '../../api'
import { modelsQueryKeys, vendorsQueryKeys } from '../../lib' import { modelsQueryKeys, vendorsQueryKeys } from '../../lib'
@@ -85,6 +65,8 @@ const FIELD_LABELS: Record<string, string> = {
enable_groups: 'Enable Groups', enable_groups: 'Enable Groups',
} }
const PAGE_SIZE_OPTIONS = [5, 10, 20, 50] as const
const formatValue = (value: unknown) => { const formatValue = (value: unknown) => {
if (value === null || value === undefined) return '—' if (value === null || value === undefined) return '—'
if (typeof value === 'string') return value || '—' if (typeof value === 'string') return value || '—'
@@ -348,16 +330,17 @@ export function UpstreamConflictDialog({
] ]
}, [isMobile]) }, [isMobile])
const table = useReactTable({ const { table } = useDataTable({
data: conflictRows, data: conflictRows,
columns, columns,
state: { rowSelection,
rowSelection,
},
enableRowSelection: true, enableRowSelection: true,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id, getRowId: (row) => row.id,
withFilteredRowModel: false,
withPaginationRowModel: false,
withSortedRowModel: false,
withFacetedRowModel: false,
}) })
const totalSelectedFields = table.getSelectedRowModel().rows.length const totalSelectedFields = table.getSelectedRowModel().rows.length
@@ -453,222 +436,186 @@ export function UpstreamConflictDialog({
} }
onOpenChange(nextOpen) onOpenChange(nextOpen)
}} }}
> title={t('Resolve Conflicts')}
<DialogContent description={t(
className='flex max-h-[90vh] w-full flex-col gap-4 p-4 sm:max-w-5xl sm:p-6' 'Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.'
initialFocus={!isMobile} )}
> contentClassName='w-full sm:max-w-5xl'
<div className='flex min-h-0 flex-1 flex-col gap-4 overflow-hidden'> contentHeight='min(72vh, 720px)'
<DialogHeader className='flex-shrink-0 text-start'> bodyClassName='flex flex-col gap-4'
<DialogTitle>{t('Resolve Conflicts')}</DialogTitle> initialFocus={!isMobile}
<DialogDescription> 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( {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> </span>
</DialogHeader> </div>
<div className='flex flex-col gap-2 sm:flex-row sm:justify-end'>
{!hasConflicts ? ( <Button
<div className='text-muted-foreground flex flex-1 items-center justify-center rounded-md border border-dashed p-8 text-center text-sm'> variant='outline'
{t('No conflict entries available.')} onClick={() => {
</div> setUpstreamConflicts([])
) : ( onOpenChange(false)
<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'> {t('Cancel')}
<div className='text-sm font-medium'> </Button>
{visibleModelCount} {t('model')} <Button
{visibleModelCount === 1 ? '' : 's'} {t('with conflicts')} onClick={handleApplyOverwrite}
</div> disabled={isSubmitting || !hasSelection}
<div className='text-muted-foreground text-xs'> >
{visibleFieldCount} {t('field')} {isSubmitting ? t('Applying...') : t('Apply Overwrite')}
{visibleFieldCount === 1 ? '' : 's'} {t('showing •')}{' '} </Button>
{totalSelectedFields} {t('selected')} </div>
</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>
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row'> <div className='text-muted-foreground text-xs'>
<div className='relative flex-1'> {visibleFieldCount} {t('field')}
<Search className='text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' /> {visibleFieldCount === 1 ? '' : 's'} {t('showing •')}{' '}
<Input {totalSelectedFields} {t('selected')}
value={search}
onChange={(event) => {
setSearch(event.target.value)
setPageIndex(0)
}}
placeholder={t('Search models or fields...')}
className='pl-9'
aria-label={t('Search conflicting models or fields')}
/>
</div>
<Button
variant='ghost'
size='sm'
onClick={clearSelections}
disabled={!hasSelection}
>
{t('Clear selection')}
</Button>
</div> </div>
</div> </div>
<div className='flex w-full flex-col gap-2 sm:w-auto sm:flex-row'>
{showSearchEmptyState ? ( <div className='relative flex-1'>
<div className='text-muted-foreground flex flex-1 items-center justify-center rounded-md border border-dashed p-8 text-center text-sm'> <Search className='text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
{t('No conflicts match your search.')} <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>
) : ( <Button
<div className='flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border'> variant='ghost'
<div className='flex-1 overflow-auto'> size='sm'
<div className={isMobile ? 'min-w-full' : 'min-w-[720px]'}> onClick={clearSelections}
<Table> disabled={!hasSelection}
<TableHeader> >
{table.getHeaderGroups().map((headerGroup) => ( {t('Clear selection')}
<TableRow key={headerGroup.id}> </Button>
{headerGroup.headers.map((header) => ( </div>
<TableHead key={header.id}> </div>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{paginatedRows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<div className='bg-muted/40 flex flex-col gap-2 border-t px-2 py-1.5 text-sm sm:flex-row sm:items-center sm:justify-between sm:gap-3 sm:px-3 sm:py-2'> {showSearchEmptyState ? (
<div className='text-muted-foreground text-xs'> <div className='text-muted-foreground flex flex-1 items-center justify-center rounded-md border border-dashed p-8 text-center text-sm'>
{t('Showing')} {displayStart}-{displayEnd} {t('of')}{' '} {t('No conflicts match your search.')}
{visibleFieldCount} {t('field')} </div>
{visibleFieldCount === 1 ? '' : 's'} ) : (
<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>
<div className='flex items-center justify-between gap-2 sm:flex-wrap sm:gap-3'> <div className='flex items-center gap-1'>
<div className='flex items-center gap-1.5 text-xs sm:gap-2'> <Button
<span className='hidden sm:inline'> variant='outline'
{t('Rows per page')} size='icon'
</span> className='h-7 w-7 sm:h-8 sm:w-8'
<Select onClick={() =>
items={[ setPageIndex((prev) => Math.max(0, prev - 1))
...[5, 10, 20, 50].map((size) => ({ }
value: String(size), disabled={pageIndex === 0}
label: size, aria-label={t('Previous page')}
})), >
]} <ChevronLeft className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
value={String(pageSize)} </Button>
onValueChange={(value) => { <span className='text-xs font-medium'>
setPageSize(Number(value)) {t('Page {{current}} of {{total}}', {
setPageIndex(0) current: currentPageDisplay,
}} total: totalPagesDisplay,
> })}
<SelectTrigger className='h-8 w-[70px] text-xs sm:h-8 sm:w-[72px]'> </span>
<SelectValue /> <Button
</SelectTrigger> variant='outline'
<SelectContent alignItemWithTrigger={false}> size='icon'
<SelectGroup> className='h-7 w-7 sm:h-8 sm:w-8'
{[5, 10, 20, 50].map((size) => ( onClick={() =>
<SelectItem key={size} value={String(size)}> setPageIndex((prev) =>
{size} Math.min(totalPages - 1, prev + 1)
</SelectItem> )
))} }
</SelectGroup> disabled={
</SelectContent> pageIndex >= totalPages - 1 ||
</Select> totalFilteredFields === 0
</div> }
<div className='flex items-center gap-1'> aria-label={t('Next page')}
<Button >
variant='outline' <ChevronRight className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
size='icon' </Button>
className='h-7 w-7 sm:h-8 sm:w-8'
onClick={() =>
setPageIndex((prev) => Math.max(0, prev - 1))
}
disabled={pageIndex === 0}
aria-label={t('Previous page')}
>
<ChevronLeft className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
</Button>
<span className='text-xs font-medium'>
{t('Page {{current}} of {{total}}', {
current: currentPageDisplay,
total: totalPagesDisplay,
})}
</span>
<Button
variant='outline'
size='icon'
className='h-7 w-7 sm:h-8 sm:w-8'
onClick={() =>
setPageIndex((prev) =>
Math.min(totalPages - 1, prev + 1)
)
}
disabled={
pageIndex >= totalPages - 1 ||
totalFilteredFields === 0
}
aria-label={t('Next page')}
>
<ChevronRight className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
</Button>
</div>
</div> </div>
</div> </div>
</div> </div>
)} </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> </Dialog>
) )
} }
@@ -24,14 +24,6 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { import {
Form, Form,
FormControl, FormControl,
@@ -43,6 +35,7 @@ import {
} from '@/components/ui/form' } from '@/components/ui/form'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { createVendor, updateVendor } from '../../api' import { createVendor, updateVendor } from '../../api'
import { vendorsQueryKeys, modelsQueryKeys } from '../../lib' import { vendorsQueryKeys, modelsQueryKeys } from '../../lib'
import { vendorFormSchema, type Vendor } from '../../types' import { vendorFormSchema, type Vendor } from '../../types'
@@ -53,6 +46,8 @@ type VendorMutateDialogProps = {
currentVendor?: Vendor | null currentVendor?: Vendor | null
} }
const VENDOR_MUTATE_FORM_ID = 'vendor-mutate-form'
export function VendorMutateDialog({ export function VendorMutateDialog({
open, open,
onOpenChange, onOpenChange,
@@ -118,98 +113,107 @@ export function VendorMutateDialog({
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog
<DialogContent> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle> title={isEdit ? t('Edit Vendor') : t('Create Vendor')}
{isEdit ? t('Edit Vendor') : t('Create Vendor')} description={
</DialogTitle> isEdit
<DialogDescription> ? t('Update vendor information for {{name}}', {
{isEdit name: currentVendor?.name,
? t('Update vendor information for {{name}}', { })
name: currentVendor?.name, : t('Add a new vendor to the system')
}) }
: t('Add a new vendor to the system')} contentHeight='auto'
</DialogDescription> bodyClassName='space-y-4'
</DialogHeader> 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}> <FormField
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'> control={form.control}
<FormField name='description'
control={form.control} render={({ field }) => (
name='name' <FormItem>
render={({ field }) => ( <FormLabel>{t('Description')}</FormLabel>
<FormItem> <FormControl>
<FormLabel>{t('Vendor Name *')}</FormLabel> <Textarea
<FormControl> placeholder={t('Describe this vendor...')}
<Input rows={3}
placeholder={t('OpenAI, Anthropic, etc.')} {...field}
{...field} />
/> </FormControl>
</FormControl> <FormMessage />
<FormDescription> </FormItem>
{t('The unique name for this vendor')} )}
</FormDescription> />
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name='description' name='icon'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('Description')}</FormLabel> <FormLabel>{t('Icon')}</FormLabel>
<FormControl> <FormControl>
<Textarea <Input
placeholder={t('Describe this vendor...')} placeholder={t('OpenAI, Anthropic, Google, etc.')}
rows={3} {...field}
{...field} />
/> </FormControl>
</FormControl> <FormDescription>
<FormMessage /> {t('@lobehub/icons key name')}
</FormItem> </FormDescription>
)} <FormMessage />
/> </FormItem>
)}
<FormField />
control={form.control} </form>
name='icon' </Form>
render={({ field }) => (
<FormItem>
<FormLabel>{t('Icon')}</FormLabel>
<FormControl>
<Input
placeholder={t('OpenAI, Anthropic, Google, etc.')}
{...field}
/>
</FormControl>
<FormDescription>
{t('@lobehub/icons key name')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
{t('Cancel')}
</Button>
<Button type='submit' disabled={isSaving}>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isSaving ? 'Saving...' : isEdit ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog> </Dialog>
) )
} }
@@ -27,14 +27,8 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible' } from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Dialog } from '@/components/dialog'
import { getDeployment, listDeploymentContainers } from '../../api' import { getDeployment, listDeploymentContainers } from '../../api'
export function ViewDetailsDialog({ export function ViewDetailsDialog({
@@ -116,160 +110,15 @@ export function ViewDetailsDialog({
}, [details]) }, [details])
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog
<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'> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle>{t('Deployment details')}</DialogTitle> title={t('Deployment details')}
</DialogHeader> 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'
<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'> bodyClassName='space-y-4'
<div className='flex flex-wrap items-center justify-between gap-2'> footer={
<div className='text-muted-foreground text-sm'> <>
{t('Deployment ID')}:{' '}
<span className='font-mono'>{deploymentId}</span>
</div>
<div className='grid grid-cols-2 gap-2 sm:flex sm:items-center'>
<Button variant='outline' size='sm' onClick={handleCopyId}>
<Copy className='mr-2 h-4 w-4' />
{t('Copy')}
</Button>
<Button
variant='outline'
size='sm'
onClick={handleRefresh}
disabled={isFetchingDetails || isFetchingContainers}
>
{isFetchingDetails || isFetchingContainers ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCcw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
</div>
</div>
<Separator />
{isLoadingDetails || isLoadingContainers ? (
<div className='flex items-center justify-center py-10'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : !detailsRes?.success ? (
<div className='text-muted-foreground py-10 text-center text-sm'>
{detailsRes?.message || t('Failed to fetch deployment details')}
</div>
) : (
<>
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Status')}
</div>
<div className='mt-1 font-medium'>
{String(details?.status ?? '-')}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Hardware')}
</div>
<div className='mt-1 font-medium'>
{String(details?.brand_name ?? '')}{' '}
{String(details?.hardware_name ?? '')}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Total GPUs')}
</div>
<div className='mt-1 font-medium'>
{String(
details?.total_gpus ?? details?.hardware_qty ?? '-'
)}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Containers')}
</div>
<div className='mt-1 font-medium'>{containers.length}</div>
</div>
</div>
{locations.length ? (
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>
{t('Locations')}
</div>
<div className='mt-1 flex flex-wrap gap-2 text-sm'>
{locations.map((x) => (
<span key={x} className='bg-muted rounded-md px-2 py-1'>
{x}
</span>
))}
</div>
</div>
) : null}
{containers.length ? (
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground mb-2 text-xs'>
{t('Containers')}
</div>
<div className='space-y-2'>
{containers.map((c) => {
const id = c?.container_id
if (typeof id !== 'string' || !id) return null
const status =
typeof c?.status === 'string' ? c.status : undefined
const url =
typeof c?.public_url === 'string' ? c.public_url : ''
return (
<div
key={id}
className='flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2'
>
<div className='min-w-0'>
<div className='truncate font-mono text-sm'>
{id}
</div>
<div className='text-muted-foreground text-xs'>
{status ? `${t('Status')}: ${status}` : ''}
</div>
</div>
{url ? (
<Button
variant='outline'
size='sm'
onClick={() => window.open(url, '_blank')}
>
<ExternalLink className='mr-2 h-4 w-4' />
{t('Open')}
</Button>
) : null}
</div>
)
})}
</div>
</div>
) : null}
<Collapsible className='rounded-lg border p-3'>
<CollapsibleTrigger className='cursor-pointer text-sm font-medium'>
{t('Raw JSON')}
</CollapsibleTrigger>
<CollapsibleContent>
<pre className='mt-3 max-h-[360px] overflow-auto rounded-md bg-black p-3 text-xs text-gray-200'>
{payloadJson || '-'}
</pre>
</CollapsibleContent>
</Collapsible>
</>
)}
</div>
<DialogFooter>
<Button <Button
variant='outline' variant='outline'
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
@@ -277,8 +126,151 @@ export function ViewDetailsDialog({
> >
{t('Close')} {t('Close')}
</Button> </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> </Dialog>
) )
} }
@@ -21,12 +21,6 @@ import { useQuery } from '@tanstack/react-query'
import { Download, Loader2, RefreshCcw, Terminal } from 'lucide-react' import { Download, Loader2, RefreshCcw, Terminal } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -36,6 +30,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Dialog } from '@/components/dialog'
import { getDeploymentLogs, listDeploymentContainers } from '../../api' import { getDeploymentLogs, listDeploymentContainers } from '../../api'
interface ViewLogsDialogProps { interface ViewLogsDialogProps {
@@ -142,180 +137,180 @@ export function ViewLogsDialog({
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog
<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'> open={open}
<DialogHeader> onOpenChange={onOpenChange}
<DialogTitle className='flex items-center gap-2'> title={
<Terminal className='h-5 w-5' /> <>
{t('Deployment logs')} <Terminal className='h-5 w-5' />
</DialogTitle> {t('Deployment logs')}
</DialogHeader> </>
}
<div className='mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3'> 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'
<div className='text-muted-foreground text-sm'> titleClassName='flex items-center gap-2'
{t('Deployment ID')}: {deploymentId} contentHeight='min(72vh, 720px)'
</div> bodyClassName='space-y-4'
<div className='grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center'> >
<Button <div className='mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3'>
variant='outline' <div className='text-muted-foreground text-sm'>
size='sm' {t('Deployment ID')}: {deploymentId}
onClick={() => { </div>
refetchContainers() <div className='grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center'>
refetchLogs() <Button
}} variant='outline'
disabled={isFetchingLogs || isFetchingContainers} size='sm'
> onClick={() => {
{isFetchingLogs || isFetchingContainers ? ( refetchContainers()
<Loader2 className='mr-2 h-4 w-4 animate-spin' /> refetchLogs()
) : ( }}
<RefreshCcw className='mr-2 h-4 w-4' /> disabled={isFetchingLogs || isFetchingContainers}
)} >
{t('Refresh')} {isFetchingLogs || isFetchingContainers ? (
</Button> <Loader2 className='mr-2 h-4 w-4 animate-spin' />
<Button ) : (
variant='outline' <RefreshCcw className='mr-2 h-4 w-4' />
size='sm' )}
onClick={handleDownload} {t('Refresh')}
disabled={!logsText.trim()} </Button>
> <Button
<Download className='mr-2 h-4 w-4' /> variant='outline'
{t('Download')} size='sm'
</Button> onClick={handleDownload}
<div className='col-span-2 flex items-center justify-between gap-2 rounded-md border px-3 py-1.5 sm:col-span-1'> disabled={!logsText.trim()}
<span className='text-xs'>{t('Auto refresh')}</span> >
<Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} /> <Download className='mr-2 h-4 w-4' />
</div> {t('Download')}
</Button>
<div className='col-span-2 flex items-center justify-between gap-2 rounded-md border px-3 py-1.5 sm:col-span-1'>
<span className='text-xs'>{t('Auto refresh')}</span>
<Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
</div> </div>
</div> </div>
</div>
<div className='mb-3 grid gap-2 sm:grid-cols-2 sm:gap-3'> <div className='mb-3 grid gap-2 sm:grid-cols-2 sm:gap-3'>
<div className='space-y-1'> <div className='space-y-1'>
<div className='text-muted-foreground text-xs'> <div className='text-muted-foreground text-xs'>{t('Container')}</div>
{t('Container')} <Select
</div> items={[
<Select ...containers.flatMap((c) => {
items={[ const id = c?.container_id
...containers.flatMap((c) => { 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 const id = c?.container_id
if (typeof id !== 'string' || !id) return [] if (typeof id !== 'string' || !id) return null
const status = const status =
typeof c?.status === 'string' && c.status typeof c?.status === 'string' && c.status
? ` (${c.status})` ? ` (${c.status})`
: '' : ''
return [ return (
{ <SelectItem key={id} value={id}>
value: id, {id}
label: ( {status}
<> </SelectItem>
{id} )
{status} })}
</> </SelectGroup>
), </SelectContent>
}, </Select>
]
}),
]}
value={containerId}
onValueChange={(v) => v !== null && setContainerId(v)}
disabled={isLoadingContainers || containers.length === 0}
>
<SelectTrigger>
<SelectValue
placeholder={
isLoadingContainers
? t('Loading...')
: containers.length === 0
? t('No containers')
: t('Select')
}
/>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{containers.map((c) => {
const id = c?.container_id
if (typeof id !== 'string' || !id) return null
const status =
typeof c?.status === 'string' && c.status
? ` (${c.status})`
: ''
return (
<SelectItem key={id} value={id}>
{id}
{status}
</SelectItem>
)
})}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='space-y-1'>
<div className='text-muted-foreground text-xs'>{t('Stream')}</div>
<Select
items={[
{ value: 'stdout', label: 'stdout' },
{ value: 'stderr', label: 'stderr' },
{ value: 'all', label: 'all' },
]}
value={stream}
onValueChange={(v) => {
if (v === 'stderr' || v === 'all' || v === 'stdout') {
setStream(v)
} else {
setStream('stdout')
}
}}
>
<SelectTrigger>
<SelectValue placeholder={t('Select')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='stdout'>stdout</SelectItem>
<SelectItem value='stderr'>stderr</SelectItem>
<SelectItem value='all'>all</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</div> </div>
<div className='space-y-1'>
<div <div className='text-muted-foreground text-xs'>{t('Stream')}</div>
ref={scrollRef} <Select
className='flex-1 overflow-auto rounded-md border bg-black p-3 sm:p-4' items={[
onScroll={(e) => { { value: 'stdout', label: 'stdout' },
const target = e.target as HTMLDivElement { value: 'stderr', label: 'stderr' },
const isAtBottom = { value: 'all', label: 'all' },
target.scrollHeight - target.scrollTop - target.clientHeight < 50 ]}
setAutoScroll(isAtBottom) value={stream}
}} onValueChange={(v) => {
> if (v === 'stderr' || v === 'all' || v === 'stdout') {
{isLoadingContainers || isLoadingLogs ? ( setStream(v)
<div className='flex items-center justify-center py-8'> } else {
<Loader2 className='h-6 w-6 animate-spin text-gray-400' /> setStream('stdout')
</div> }
) : containers.length === 0 ? ( }}
<div className='py-8 text-center text-gray-400'> >
{t('No containers')} <SelectTrigger>
</div> <SelectValue placeholder={t('Select')} />
) : !containerId ? ( </SelectTrigger>
<div className='py-8 text-center text-gray-400'> <SelectContent alignItemWithTrigger={false}>
{t('Please select a container')} <SelectGroup>
</div> <SelectItem value='stdout'>stdout</SelectItem>
) : !logsText.trim() ? ( <SelectItem value='stderr'>stderr</SelectItem>
<div className='py-8 text-center text-gray-400'>{t('No logs')}</div> <SelectItem value='all'>all</SelectItem>
) : ( </SelectGroup>
<div className='font-mono text-sm'> </SelectContent>
{logLines.map((line, idx) => ( </Select>
<div key={idx} className='whitespace-pre-wrap text-gray-200'>
{line}
</div>
))}
</div>
)}
</div> </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> </Dialog>
) )
} }
+15 -15
View File
@@ -27,8 +27,9 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } 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 { GroupBadge } from '@/components/group-badge'
import { ProviderBadge } from '@/components/provider-badge'
import { StatusBadge, StatusBadgeList } from '@/components/status-badge' import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
import { TableId } from '@/components/table-id' import { TableId } from '@/components/table-id'
import { import {
@@ -41,6 +42,12 @@ import type { Model, Vendor } from '../types'
import { DataTableRowActions } from './data-table-row-actions' import { DataTableRowActions } from './data-table-row-actions'
import { DescriptionCell } from './description-cell' 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 * 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 || vendorMap[model.vendor_id || 0]?.icon ||
model.model_name?.[0] || model.model_name?.[0] ||
'N' '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, size: 70,
enableSorting: false, enableSorting: false,
@@ -259,18 +270,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
return <span className='text-muted-foreground text-xs'>-</span> return <span className='text-muted-foreground text-xs'>-</span>
} }
const icon = vendor.icon ? getLobeIcon(vendor.icon, 14) : null return <ProviderBadge iconKey={vendor.icon} label={vendor.name} />
return (
<div className='flex items-center gap-1.5'>
{icon}
<StatusBadge
label={vendor.name}
autoColor={vendor.name}
size='sm'
/>
</div>
)
}, },
filterFn: (row, id, value) => { filterFn: (row, id, value) => {
if (!value || value.length === 0 || value.includes('all')) return true if (!value || value.length === 0 || value.includes('all')) return true
+12 -36
View File
@@ -16,19 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com 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 { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router' import { getRouteApi } from '@tanstack/react-router'
import {
getCoreRowModel,
useReactTable,
type SortingState,
type VisibilityState,
} from '@tanstack/react-table'
import { useMediaQuery } from '@/hooks' import { useMediaQuery } from '@/hooks'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useTableUrlState } from '@/hooks/use-table-url-state' 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 { getModels, searchModels, getVendors } from '../api'
import { import {
DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE,
@@ -47,15 +41,6 @@ export function ModelsTable() {
const { selectedVendor } = useModels() const { selectedVendor } = useModels()
const isMobile = useMediaQuery('(max-width: 640px)') 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 // URL state management
const { const {
globalFilter, globalFilter,
@@ -176,37 +161,28 @@ export function ModelsTable() {
const columns = useModelsColumns(vendors) const columns = useModelsColumns(vendors)
// React Table instance // React Table instance
const table = useReactTable({ const { table } = useDataTable({
data: models, data: models,
columns, columns,
pageCount: Math.ceil(totalCount / pagination.pageSize), totalCount,
state: { initialColumnVisibility: {
sorting, description: false,
columnFilters, bound_channels: false,
columnVisibility, quota_types: false,
rowSelection,
pagination,
globalFilter,
}, },
columnFilters,
pagination,
globalFilter,
enableRowSelection: true, enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange, onColumnFiltersChange,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange, onPaginationChange,
onGlobalFilterChange, onGlobalFilterChange,
getCoreRowModel: getCoreRowModel(),
manualPagination: true, manualPagination: true,
manualSorting: true, manualSorting: true,
manualFiltering: 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 // Prepare filter options
const vendorFilterOptions = [ const vendorFilterOptions = [
{ {
+19 -17
View File
@@ -119,7 +119,7 @@ function ModelsContent() {
return ( return (
<> <>
<SectionPageLayout> <SectionPageLayout fixedContent>
<SectionPageLayout.Title>{t(meta.titleKey)}</SectionPageLayout.Title> <SectionPageLayout.Title>{t(meta.titleKey)}</SectionPageLayout.Title>
<SectionPageLayout.Actions> <SectionPageLayout.Actions>
{activeSection === 'metadata' ? ( {activeSection === 'metadata' ? (
@@ -132,7 +132,7 @@ function ModelsContent() {
)} )}
</SectionPageLayout.Actions> </SectionPageLayout.Actions>
<SectionPageLayout.Content> <SectionPageLayout.Content>
<div className='space-y-4'> <div className='flex h-full min-h-0 flex-col gap-4'>
<Tabs value={activeSection} onValueChange={handleSectionChange}> <Tabs value={activeSection} onValueChange={handleSectionChange}>
<TabsList className='max-w-full flex-wrap justify-start group-data-horizontal/tabs:h-auto'> <TabsList className='max-w-full flex-wrap justify-start group-data-horizontal/tabs:h-auto'>
{MODELS_SECTION_IDS.map((section) => ( {MODELS_SECTION_IDS.map((section) => (
@@ -142,21 +142,23 @@ function ModelsContent() {
))} ))}
</TabsList> </TabsList>
</Tabs> </Tabs>
{activeSection === 'metadata' ? ( <div className='min-h-0 flex-1'>
<ModelsTable /> {activeSection === 'metadata' ? (
) : ( <ModelsTable />
<DeploymentAccessGuard ) : (
loading={deploymentLoading} <DeploymentAccessGuard
loadingPhase={loadingPhase} loading={deploymentLoading}
isEnabled={isIoNetEnabled} loadingPhase={loadingPhase}
connectionLoading={connectionLoading} isEnabled={isIoNetEnabled}
connectionOk={connectionOk} connectionLoading={connectionLoading}
connectionError={connectionError} connectionOk={connectionOk}
onRetry={testConnection} connectionError={connectionError}
> onRetry={testConnection}
<DeploymentsTable /> >
</DeploymentAccessGuard> <DeploymentsTable />
)} </DeploymentAccessGuard>
)}
</div>
</div> </div>
</SectionPageLayout.Content> </SectionPageLayout.Content>
</SectionPageLayout> </SectionPageLayout>
@@ -22,14 +22,7 @@ import { useTranslation } from 'react-i18next'
import { useSystemConfigStore } from '@/stores/system-config-store' import { useSystemConfigStore } from '@/stores/system-config-store'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { import { StaticDataTable } from '@/components/data-table'
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { import {
BILLING_PRICING_VARS, BILLING_PRICING_VARS,
MATCH_CONTAINS, MATCH_CONTAINS,
@@ -307,86 +300,82 @@ export function DynamicPricingBreakdown({
) )
})} })}
</div> </div>
<div className='hidden overflow-x-auto sm:block'> <StaticDataTable
<Table className='text-sm'> className='hidden rounded-none border-0 sm:block'
<TableHeader> tableClassName='text-sm'
<TableRow className='hover:bg-transparent'> headerRowClassName='hover:bg-transparent'
<TableHead className='text-muted-foreground py-2 font-medium'> data={tiers}
{t('Tier')} getRowKey={(_tier, index) => `tier-${index}`}
</TableHead> getRowClassName={(tier) => {
{visiblePriceFields.map((v) => ( const isMatched =
<TableHead normalizedMatchedTierLabel !== '' &&
key={v.field} normalizeTierLabel(tier.label) === normalizedMatchedTierLabel
className='text-muted-foreground py-2 text-right font-medium' return cn(
> isMatched &&
{t(v.shortLabel)} 'bg-emerald-50/70 hover:bg-emerald-50/70 dark:bg-emerald-500/10 dark:hover:bg-emerald-500/10'
</TableHead> )
))} }}
</TableRow> columns={[
</TableHeader> {
<TableBody> id: 'tier',
{tiers.map((tier, i) => { header: t('Tier'),
const condSummary = formatConditionSummary(tier.conditions, t) className: 'text-muted-foreground py-2 font-medium',
cellClassName: 'py-2.5 align-top',
cell: (tier) => {
const condSummary = formatConditionSummary(
tier.conditions,
t
)
const isMatched = const isMatched =
normalizedMatchedTierLabel !== '' && normalizedMatchedTierLabel !== '' &&
normalizeTierLabel(tier.label) === normalizeTierLabel(tier.label) === normalizedMatchedTierLabel
normalizedMatchedTierLabel
return ( return (
<TableRow <>
key={`tier-${i}`} <div className='flex flex-wrap items-center gap-1.5'>
className={cn( <Badge
isMatched && variant='secondary'
'bg-emerald-50/70 hover:bg-emerald-50/70 dark:bg-emerald-500/10 dark:hover:bg-emerald-500/10' className='bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
)} >
> {tier.label || t('Default')}
<TableCell className='py-2.5 align-top'> </Badge>
<div className='flex flex-wrap items-center gap-1.5'> {isMatched && (
<Badge <Badge
variant='secondary' 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> </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> </div>
{visiblePriceFields.map((v) => { {condSummary && (
const value = Number( <div className='text-muted-foreground mt-1 text-xs'>
tier[v.field as string as keyof ParsedTier] || 0 {condSummary}
) </div>
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>
) )
})} },
</TableBody> },
</Table> ...visiblePriceFields.map((v, index) => ({
</div> 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> </div>
)} )}
@@ -32,19 +32,15 @@ import type { BundledLanguage } from 'shiki/bundle/web'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useStatus } from '@/hooks/use-status' import { useStatus } from '@/hooks/use-status'
import { Badge } from '@/components/ui/badge' 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 { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { import {
CodeBlock, CodeBlock,
CodeBlockCopyButton, CodeBlockCopyButton,
} from '@/components/ai-elements/code-block' } from '@/components/ai-elements/code-block'
import {
StaticDataTable,
staticDataTableClassNames as tableStyles,
} from '@/components/data-table'
import { import {
buildRateLimits, buildRateLimits,
buildSupportedParameters, buildSupportedParameters,
@@ -570,53 +566,62 @@ function SupportedParametersSection(props: { model: PricingModel }) {
return ( return (
<section> <section>
<SectionTitle icon={Sigma}>{t('Supported parameters')}</SectionTitle> <SectionTitle icon={Sigma}>{t('Supported parameters')}</SectionTitle>
<div className='border-border/60 overflow-hidden rounded-lg border'> <StaticDataTable
<Table> className={tableStyles.sectionContainer}
<TableHeader> headerRowClassName={tableStyles.mutedHeaderRow}
<TableRow className='bg-muted/30 hover:bg-muted/30'> data={params}
<TableHead className='h-9 w-44'>{t('Parameter')}</TableHead> getRowKey={(param) => param.name}
<TableHead className='h-9 w-24'>{t('Type')}</TableHead> getRowClassName={() => 'hover:bg-muted/20'}
<TableHead className='h-9 w-32'>{t('Default / range')}</TableHead> columns={[
<TableHead className='h-9'>{t('Description')}</TableHead> {
</TableRow> id: 'parameter',
</TableHeader> header: t('Parameter'),
<TableBody> className: 'h-9 w-44',
{params.map((p) => ( cellClassName: tableStyles.topCell,
<TableRow key={p.name} className='hover:bg-muted/20'> cell: (p) => (
<TableCell className='py-2 align-top'> <div className='flex items-center gap-1.5'>
<div className='flex items-center gap-1.5'> <code className='font-mono text-sm font-medium'>{p.name}</code>
<code className='font-mono text-sm font-medium'> {p.required && (
{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'>
<Badge <Badge
variant='secondary' variant='outline'
className='h-7 rounded-full px-2.5 font-mono text-sm font-normal' className='h-6 border-rose-500/40 px-2 text-sm text-rose-600 dark:text-rose-400'
> >
{p.type} {t('required')}
</Badge> </Badge>
</TableCell> )}
<TableCell className='py-2 align-top'> </div>
<ParamRangeCell param={p} /> ),
</TableCell> },
<TableCell className='text-muted-foreground py-2 align-top'> {
{t(p.descriptionKey)} id: 'type',
</TableCell> header: t('Type'),
</TableRow> className: 'h-9 w-24',
))} cellClassName: tableStyles.topCell,
</TableBody> cell: (p) => (
</Table> <Badge
</div> 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> </section>
) )
} }
@@ -671,34 +676,43 @@ function RateLimitsSection(props: { model: PricingModel }) {
return ( return (
<section> <section>
<SectionTitle icon={Gauge}>{t('Rate limits')}</SectionTitle> <SectionTitle icon={Gauge}>{t('Rate limits')}</SectionTitle>
<div className='border-border/60 overflow-hidden rounded-lg border'> <StaticDataTable
<Table> className={tableStyles.sectionContainer}
<TableHeader> headerRowClassName={tableStyles.mutedHeaderRow}
<TableRow className='bg-muted/30 hover:bg-muted/30'> data={limits}
<TableHead className='h-9'>{t('Group')}</TableHead> getRowKey={(limit) => limit.group}
<TableHead className='h-9 text-right'>RPM</TableHead> getRowClassName={() => 'hover:bg-muted/20'}
<TableHead className='h-9 text-right'>TPM</TableHead> columns={[
<TableHead className='h-9 text-right'>RPD</TableHead> {
</TableRow> id: 'group',
</TableHeader> header: t('Group'),
<TableBody> className: 'h-9',
{limits.map((l) => ( cellClassName: 'py-2 font-mono',
<TableRow key={l.group} className='hover:bg-muted/20'> cell: (limit) => limit.group,
<TableCell className='py-2 font-mono'>{l.group}</TableCell> },
<TableCell className='py-2 text-right font-mono'> {
{formatRateLimit(l.rpm)} id: 'rpm',
</TableCell> header: 'RPM',
<TableCell className='py-2 text-right font-mono'> className: 'h-9 text-right',
{formatRateLimit(l.tpm)} cellClassName: tableStyles.topNumericCell,
</TableCell> cell: (limit) => formatRateLimit(limit.rpm),
<TableCell className='py-2 text-right font-mono'> },
{formatRateLimit(l.rpd)} {
</TableCell> id: 'tpm',
</TableRow> header: 'TPM',
))} className: 'h-9 text-right',
</TableBody> cellClassName: tableStyles.topNumericCell,
</Table> cell: (limit) => formatRateLimit(limit.tpm),
</div> },
{
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'> <p className='text-muted-foreground mt-2 text-[11px] leading-relaxed'>
{t( {t(
'RPM = requests per minute, TPM = tokens per minute, RPD = requests per day. Limits apply per token group.' '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 { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
Table, StaticDataTable,
TableBody, staticDataTableClassNames as tableStyles,
TableCell, } from '@/components/data-table'
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { import {
buildAppRankings, buildAppRankings,
formatTokenVolume, formatTokenVolume,
@@ -123,9 +119,6 @@ export function ModelDetailsApps(props: { model: PricingModel }) {
const totalMonthlyTokens = apps.reduce((s, a) => s + a.monthly_tokens, 0) const totalMonthlyTokens = apps.reduce((s, a) => s + a.monthly_tokens, 0)
const top = apps[0] const top = apps[0]
const headerCellClass =
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase'
return ( return (
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
<div className='grid grid-cols-1 gap-2 sm:grid-cols-3'> <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> </div>
<div className='overflow-x-auto rounded-lg border'> <StaticDataTable
<Table className='text-sm'> className='rounded-lg'
<TableHeader> tableClassName='text-sm'
<TableRow className='hover:bg-transparent'> headerRowClassName={tableStyles.compactHeaderRow}
<TableHead className={cn(headerCellClass, 'w-12')}>#</TableHead> data={apps}
<TableHead className={headerCellClass}>{t('App')}</TableHead> getRowKey={(app) => `${app.rank}-${app.name}`}
<TableHead columns={[
className={cn(headerCellClass, 'hidden md:table-cell')} {
> id: 'rank',
{t('Category')} header: '#',
</TableHead> className: cn(tableStyles.compactHeaderCell, 'w-12'),
<TableHead className={`${headerCellClass} text-right`}> cellClassName: tableStyles.compactCell,
{t('Monthly tokens')} cell: (app) => <RankBadge rank={app.rank} />,
</TableHead> },
<TableHead className={`${headerCellClass} text-right`}> {
{t('30d change')} id: 'app',
</TableHead> header: t('App'),
</TableRow> className: tableStyles.compactHeaderCell,
</TableHeader> cellClassName: tableStyles.compactCell,
<TableBody> cell: (app) => (
{apps.map((app) => ( <div className='flex items-center gap-3'>
<TableRow key={`${app.rank}-${app.name}`}> <span className='bg-muted text-muted-foreground inline-flex size-7 shrink-0 items-center justify-center rounded-md font-bold'>
<TableCell className='py-2.5'> {app.initial}
<RankBadge rank={app.rank} /> </span>
</TableCell> <div className='min-w-0'>
<TableCell className='py-2.5'> <div className='text-sm font-medium'>
<div className='flex items-center gap-3'> <AppLink app={app} />
<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>
</div> </div>
</TableCell> <p className='text-muted-foreground line-clamp-1 text-sm'>
<TableCell className='text-muted-foreground hidden py-2.5 md:table-cell'> {app.description}
{app.category} </p>
</TableCell> </div>
<TableCell className='py-2.5 text-right font-mono tabular-nums'> </div>
{formatTokenVolume(app.monthly_tokens)} ),
</TableCell> },
<TableCell className='py-2.5 text-right'> {
<GrowthChip value={app.growth_pct} /> id: 'category',
</TableCell> header: t('Category'),
</TableRow> className: cn(
))} tableStyles.compactHeaderCell,
</TableBody> 'hidden md:table-cell'
</Table> ),
</div> 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'> <p className='text-muted-foreground/60 text-[11px] leading-relaxed'>
{t( {t(
@@ -30,6 +30,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { StaticDataTable } from '@/components/data-table'
import type { Modality } from '../types' import type { Modality } from '../types'
type IconComponent = React.ComponentType<{ className?: string }> type IconComponent = React.ComponentType<{ className?: string }>
@@ -95,79 +96,65 @@ export function ModalitiesMatrix(props: {
const inputSet = new Set(props.input) const inputSet = new Set(props.input)
const outputSet = new Set(props.output) const outputSet = new Set(props.output)
const renderRow = (label: string, set: Set<Modality>) => ( return (
<tr> <StaticDataTable
<th className='rounded-lg'
scope='row' tableClassName='text-sm'
className='text-muted-foreground bg-muted/30 px-3 py-2 text-left text-[11px] font-medium tracking-wider uppercase' headerRowClassName='bg-muted/40'
> data={[
{label} { label: t('Input'), set: inputSet },
</th> { label: t('Output'), set: outputSet },
{ALL_MODALITIES.map((modality) => { ]}
const enabled = set.has(modality) getRowKey={(row) => row.label}
const Icon = MODALITY_META[modality].icon columns={[
return ( {
<td id: 'modality',
key={modality} header: t('Modality'),
className={cn( 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', 'border-l px-3 py-2 text-center',
enabled row.set.has(modality)
? 'bg-emerald-50/40 dark:bg-emerald-500/10' ? 'bg-emerald-50/40 dark:bg-emerald-500/10'
: 'bg-background' : 'bg-background'
)} ),
> cell: (row: { label: string; set: Set<Modality> }) => {
<span const enabled = row.set.has(modality)
className={cn( const Icon = MODALITY_META[modality].icon
'inline-flex items-center justify-center', return (
enabled <span
? 'text-emerald-700 dark:text-emerald-300' className={cn(
: 'text-muted-foreground/40' 'inline-flex items-center justify-center',
)} enabled
aria-label={ ? 'text-emerald-700 dark:text-emerald-300'
enabled : 'text-muted-foreground/40'
? t('{{modality}} supported', { )}
modality: t(MODALITY_META[modality].labelKey), aria-label={
}) enabled
: t('{{modality}} not supported', { ? t('{{modality}} supported', {
modality: t(MODALITY_META[modality].labelKey), 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'
> >
{t(MODALITY_META[modality].labelKey)} <Icon className='size-4' />
</th> </span>
))} )
</tr> },
</thead> })),
<tbody> ]}
{renderRow(t('Input'), inputSet)} />
{renderRow(t('Output'), outputSet)}
</tbody>
</table>
</div>
) )
} }
@@ -22,13 +22,9 @@ import { AlertTriangle, HeartPulse, Timer } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
Table, StaticDataTable,
TableBody, staticDataTableClassNames as tableStyles,
TableCell, } from '@/components/data-table'
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { GroupBadge } from '@/components/group-badge' import { GroupBadge } from '@/components/group-badge'
import { getPerfMetrics } from '@/features/performance-metrics/api' import { getPerfMetrics } from '@/features/performance-metrics/api'
import { import {
@@ -218,9 +214,6 @@ export function ModelDetailsPerformance(props: { model: PricingModel }) {
intent = 'default' intent = 'default'
} }
const headerCellClass =
'text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase'
return ( return (
<div className='flex flex-col gap-4'> <div className='flex flex-col gap-4'>
<div className='grid grid-cols-1 gap-2 sm:grid-cols-3'> <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')} title={t('Per-group performance')}
description={t('Average latency, TTFT, TPS, and success rate')} description={t('Average latency, TTFT, TPS, and success rate')}
/> />
<div className='overflow-x-auto rounded-lg border'> <StaticDataTable
<Table className='text-sm'> className='rounded-lg'
<TableHeader> tableClassName='text-sm'
<TableRow className='hover:bg-transparent'> headerRowClassName={tableStyles.compactHeaderRow}
<TableHead className={headerCellClass}>{t('Group')}</TableHead> data={performances}
<TableHead className={`${headerCellClass} text-right`}> getRowKey={(perf) => perf.group}
TPS columns={[
</TableHead> {
<TableHead className={`${headerCellClass} text-right`}> id: 'group',
{t('Average TTFT')} header: t('Group'),
</TableHead> className: tableStyles.compactHeaderCell,
<TableHead className={`${headerCellClass} text-right`}> cellClassName: tableStyles.compactCell,
{t('Average latency')} cell: (perf) => <GroupBadge group={perf.group} size='sm' />,
</TableHead> },
<TableHead {
className={`${headerCellClass} min-w-[180px] text-left`} id: 'tps',
> header: 'TPS',
{t('Success rate')} className: tableStyles.compactHeaderCellRight,
</TableHead> cellClassName: tableStyles.compactNumericCell,
</TableRow> cell: (perf) => formatThroughput(perf.avg_tps),
</TableHeader> },
<TableBody> {
{performances.map((perf) => ( id: 'ttft',
<TableRow key={perf.group}> header: t('Average TTFT'),
<TableCell className='py-2.5'> className: tableStyles.compactHeaderCellRight,
<GroupBadge group={perf.group} size='sm' /> cellClassName: tableStyles.compactNumericCell,
</TableCell> cell: (perf) => formatLatency(perf.avg_ttft_ms),
<TableCell className='py-2.5 text-right font-mono'> },
{formatThroughput(perf.avg_tps)} {
</TableCell> id: 'latency',
<TableCell className='py-2.5 text-right font-mono'> header: t('Average latency'),
{formatLatency(perf.avg_ttft_ms)} className: tableStyles.compactHeaderCellRight,
</TableCell> cellClassName: tableStyles.compactMutedNumericCell,
<TableCell className='text-muted-foreground py-2.5 text-right font-mono'> cell: (perf) => formatLatency(perf.avg_latency_ms),
{formatLatency(perf.avg_latency_ms)} },
</TableCell> {
<TableCell className='py-2.5'> id: 'success',
<UptimeSparkline header: t('Success rate'),
size='sm' className: cn(tableStyles.compactHeaderCell, 'min-w-[180px]'),
series={uptimeByGroup[perf.group] ?? []} cellClassName: tableStyles.compactCell,
/> cell: (perf) => (
</TableCell> <UptimeSparkline
</TableRow> size='sm'
))} series={uptimeByGroup[perf.group] ?? []}
</TableBody> />
</Table> ),
</div> },
]}
/>
</section> </section>
<section> <section>
+167 -177
View File
@@ -32,16 +32,9 @@ import {
SheetTitle, SheetTitle,
} from '@/components/ui/sheet' } from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton' 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { CopyButton } from '@/components/copy-button' import { CopyButton } from '@/components/copy-button'
import { StaticDataTable } from '@/components/data-table'
import { sideDrawerContentClassName } from '@/components/drawer-layout' import { sideDrawerContentClassName } from '@/components/drawer-layout'
import { GroupBadge } from '@/components/group-badge' import { GroupBadge } from '@/components/group-badge'
import { PublicLayout } from '@/components/layout' import { PublicLayout } from '@/components/layout'
@@ -269,9 +262,7 @@ function ModelHeader(props: { model: PricingModel }) {
const { t } = useTranslation() const { t } = useTranslation()
const model = props.model const model = props.model
const modelIconKey = model.icon || model.vendor_icon const modelIconKey = model.icon || model.vendor_icon
const modelIcon = modelIconKey const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 20) : null
? getLobeIcon(modelIconKey, 20)
: null
const description = model.description || model.vendor_description || null const description = model.description || model.vendor_description || null
const tags = parseTags(model.tags) const tags = parseTags(model.tags)
const isSpecialExpression = 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 // Group pricing table
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -676,20 +701,27 @@ function GroupPricingSection(props: {
) )
} }
const priceFields = Array.from( const priceFields = getDynamicPriceFields(dynamicTiers, {
new Map( tokenUnit: props.tokenUnit,
dynamicTiers showRechargePrice,
.flatMap((tier) => priceRate: props.priceRate,
getDynamicPriceEntries(tier, { usdExchangeRate: props.usdExchangeRate,
tokenUnit: props.tokenUnit, groupRatioMultiplier: 1,
showRechargePrice, })
priceRate: props.priceRate, const formattedPricesByGroup = new Map(
usdExchangeRate: props.usdExchangeRate, availableGroups.map((group) => {
groupRatioMultiplier: 1, const ratio = props.groupRatio[group] || 1
}) return [
) group,
.map((entry) => [entry.field, entry]) getDynamicFormattedPricesByTier(dynamicTiers, {
).values() tokenUnit: props.tokenUnit,
showRechargePrice,
priceRate: props.priceRate,
usdExchangeRate: props.usdExchangeRate,
groupRatioMultiplier: ratio,
}),
] as const
})
) )
return ( return (
@@ -699,6 +731,10 @@ function GroupPricingSection(props: {
<div className='space-y-3'> <div className='space-y-3'>
{availableGroups.map((group) => { {availableGroups.map((group) => {
const ratio = props.groupRatio[group] || 1 const ratio = props.groupRatio[group] || 1
const formattedPricesByTier =
formattedPricesByGroup.get(group) ??
new Map<DynamicPricingTier, Map<string, string>>()
return ( return (
<div key={group} className='overflow-hidden rounded-lg border'> <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'> <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 {ratio}x
</span> </span>
</div> </div>
<div className='overflow-x-auto'> <StaticDataTable
<Table className='text-sm'> className='rounded-none border-0'
<TableHeader> tableClassName='text-sm'
<TableRow className='hover:bg-transparent'> headerRowClassName='hover:bg-transparent'
<TableHead className={thClass}>{t('Tier')}</TableHead> data={dynamicTiers}
{priceFields.map((entry) => ( getRowKey={(tier, tierIndex) =>
<TableHead `${group}-${tier.label || tierIndex}`
key={entry.field} }
className={`${thClass} text-right`} columns={[
> {
{t(entry.shortLabel)} id: 'tier',
</TableHead> header: t('Tier'),
))} className: thClass,
</TableRow> cellClassName: 'text-muted-foreground py-2.5',
</TableHeader> cell: (tier) => tier.label || t('Default'),
<TableBody> },
{dynamicTiers.map((tier, tierIndex) => { ...priceFields.map((fieldEntry) => ({
const entries = getDynamicPriceEntries(tier, { id: fieldEntry.field,
tokenUnit: props.tokenUnit, header: t(fieldEntry.shortLabel),
showRechargePrice, className: `${thClass} text-right`,
priceRate: props.priceRate, cellClassName: 'py-2.5 text-right font-mono',
usdExchangeRate: props.usdExchangeRate, cell: (tier: (typeof dynamicTiers)[number]) =>
groupRatioMultiplier: ratio, formattedPricesByTier
}) .get(tier)
const entryMap = new Map( ?.get(fieldEntry.field) ?? '-',
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>
</div> </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 ( return (
<section> <section>
<SectionTitle>{t('Pricing by Group')}</SectionTitle> <SectionTitle>{t('Pricing by Group')}</SectionTitle>
<AutoGroupChain model={props.model} autoGroups={props.autoGroups} /> <AutoGroupChain model={props.model} autoGroups={props.autoGroups} />
<div className='-mx-4 overflow-x-auto sm:mx-0'> <StaticDataTable
<Table className='text-sm'> className='-mx-4 rounded-none border-0 sm:mx-0'
<TableHeader> tableClassName='text-sm'
<TableRow className='hover:bg-transparent'> headerRowClassName='hover:bg-transparent'
<TableHead className={thClass}>{t('Group')}</TableHead> data={availableGroups}
<TableHead className={thClass}>{t('Ratio')}</TableHead> getRowKey={(group) => group}
{isTokenBased ? ( columns={[
<> {
<TableHead className={`${thClass} text-right`}> id: 'group',
{t('Input')} header: t('Group'),
</TableHead> className: thClass,
<TableHead className={`${thClass} text-right`}> cellClassName: 'py-2.5',
{t('Output')} cell: (group) => <GroupBadge group={group} size='sm' />,
</TableHead> },
{extraPriceTypes.map((ep) => ( {
<TableHead id: 'ratio',
key={ep.type} header: t('Ratio'),
className={`${thClass} text-right`} className: thClass,
> cellClassName: 'text-muted-foreground py-2.5 font-mono',
{ep.label} cell: (group) => `${props.groupRatio[group] || 1}x`,
</TableHead> },
))} ...(isTokenBased
</> ? [
) : ( {
<TableHead className={`${thClass} text-right`}> id: 'input',
{t('Price')} header: t('Input'),
</TableHead> className: `${thClass} text-right`,
)} cellClassName: 'py-2.5 text-right font-mono',
</TableRow> cell: (group: string) => renderGroupPrice(group, 'input'),
</TableHeader> },
<TableBody> {
{availableGroups.map((group) => { id: 'output',
const ratio = props.groupRatio[group] || 1 header: t('Output'),
return ( className: `${thClass} text-right`,
<TableRow key={group}> cellClassName: 'py-2.5 text-right font-mono',
<TableCell className='py-2.5'> cell: (group: string) => renderGroupPrice(group, 'output'),
<GroupBadge group={group} size='sm' /> },
</TableCell> ...extraPriceTypes.map((ep) => ({
<TableCell className='text-muted-foreground py-2.5 font-mono'> id: ep.type,
{ratio}x header: ep.label,
</TableCell> className: `${thClass} text-right`,
{isTokenBased ? ( cellClassName: 'py-2.5 text-right font-mono',
<> cell: (group: string) => renderGroupPrice(group, ep.type),
<TableCell className='py-2.5 text-right font-mono'> })),
{formatGroupPrice( ]
props.model, : [
group, {
'input', id: 'price',
props.tokenUnit, header: t('Price'),
showRechargePrice, className: `${thClass} text-right`,
props.priceRate, cellClassName: 'py-2.5 text-right font-mono',
props.usdExchangeRate, cell: renderFixedGroupPrice,
props.groupRatio },
)} ]),
</TableCell> ]}
<TableCell className='py-2.5 text-right font-mono'> />
{formatGroupPrice( <div className='-mx-4 sm:mx-0'>
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>
{isTokenBased && ( {isTokenBased && (
<p className='text-muted-foreground/40 mt-1.5 px-4 text-[10px] sm:px-0'> <p className='text-muted-foreground/40 mt-1.5 px-4 text-[10px] sm:px-0'>
{t('Prices shown per')} {tokenUnitLabel} tokens {t('Prices shown per')} {tokenUnitLabel} tokens

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