diff --git a/controller/channel_priority.go b/controller/channel_priority.go new file mode 100644 index 0000000000..701c09b1e1 --- /dev/null +++ b/controller/channel_priority.go @@ -0,0 +1,40 @@ +package controller + +import ( + "errors" + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" +) + +func GetChannelPriorityOrder(c *gin.Context) { + order, err := model.GetChannelPriorityOrder() + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": order}) +} + +func ReorderChannelPriority(c *gin.Context) { + var move model.ChannelPriorityMove + if err := common.DecodeJson(c.Request.Body, &move); err != nil { + common.ApiErrorT(c, "Invalid channel priority move") + return + } + order, err := model.ReorderChannelPriority(move) + if err != nil { + if errors.Is(err, model.ErrChannelPriorityConflict) || errors.Is(err, model.ErrChannelPriorityInvalid) || errors.Is(err, model.ErrChannelPriorityRange) { + common.ApiErrorT(c, err.Error()) + } else { + common.ApiError(c, err) + } + return + } + if !move.Preview && len(order.Changes) > 0 { + model.InitChannelCache() + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": order}) +} diff --git a/model/channel.go b/model/channel.go index b71f486035..202f51cb4a 100644 --- a/model/channel.go +++ b/model/channel.go @@ -105,10 +105,15 @@ func NewChannelSortOptions(sortBy string, sortOrder string, idSort bool) Channel func (options ChannelSortOptions) Apply(query *gorm.DB) *gorm.DB { if columnName, ok := channelSortColumns[options.SortBy]; ok { + column := clause.Column{Name: columnName} + if columnName == "priority" { + // Routing treats a missing priority as zero on every database. + column = clause.Column{Name: "COALESCE(priority, 0)", Raw: true} + } return query.Order(clause.OrderByColumn{ - Column: clause.Column{Name: columnName}, + Column: column, Desc: options.SortOrder != "asc", - }) + }).Order(clause.OrderByColumn{Column: clause.Column{Name: "id"}, Desc: true}) } if options.IDSort { return query.Order(clause.OrderByColumn{ @@ -117,9 +122,9 @@ func (options ChannelSortOptions) Apply(query *gorm.DB) *gorm.DB { }) } return query.Order(clause.OrderByColumn{ - Column: clause.Column{Name: "priority"}, + Column: clause.Column{Name: "COALESCE(priority, 0)", Raw: true}, Desc: true, - }) + }).Order(clause.OrderByColumn{Column: clause.Column{Name: "id"}, Desc: true}) } func resolveChannelSortOptions(idSort bool, sortOptions []ChannelSortOptions) ChannelSortOptions { diff --git a/model/channel_priority.go b/model/channel_priority.go new file mode 100644 index 0000000000..27391d120a --- /dev/null +++ b/model/channel_priority.go @@ -0,0 +1,244 @@ +package model + +import ( + "crypto/sha256" + "errors" + "fmt" + "slices" + + "github.com/QuantumNous/new-api/common" + "gorm.io/gorm" +) + +// Keep priorities lossless in the web console's JSON number representation. +const maxSortablePriority int64 = 1<<53 - 1 + +var ErrChannelPriorityConflict = errors.New("Channel priorities changed. Refresh and try again.") +var ErrChannelPriorityInvalid = errors.New("Invalid channel priority move") +var ErrChannelPriorityRange = errors.New("Channel priority is outside the sortable range") + +type ChannelPriority struct { + ID int `json:"id"` + Priority *int64 `json:"priority"` +} + +func (item ChannelPriority) value() int64 { + if item.Priority == nil { + return 0 + } + return *item.Priority +} + +type ChannelPriorityChange struct { + ID int `json:"id"` + Previous *int64 `json:"previous"` + Priority *int64 `json:"priority"` +} + +type ChannelPriorityOrder struct { + Revision string `json:"revision"` + Items []ChannelPriority `json:"items"` + Changes []ChannelPriorityChange `json:"changes"` +} + +type ChannelPriorityMove struct { + Revision string `json:"revision"` + ID int `json:"id"` + TargetID int `json:"target_id"` + Position string `json:"position"` // before / after use descending priority order + Preview bool `json:"preview"` + Restore []ChannelPriorityChange `json:"restore,omitempty"` +} + +func channelPriorityRevision(items []ChannelPriority) string { + data, _ := common.Marshal(items) + return fmt.Sprintf("%x", sha256.Sum256(data)) +} + +func GetChannelPriorityOrder() (ChannelPriorityOrder, error) { + result := ChannelPriorityOrder{Items: []ChannelPriority{}, Changes: []ChannelPriorityChange{}} + err := DB.Model(&Channel{}).Select("id", "priority").Order("id").Find(&result.Items).Error + result.Revision = channelPriorityRevision(result.Items) + return result, err +} + +// ReorderChannelPriority updates only priorities, including the routing copy in +// abilities. A snapshot check also protects preview/commit and undo from stale +// clients; row locks serialize concurrent reorders on MySQL and PostgreSQL. +func ReorderChannelPriority(move ChannelPriorityMove) (ChannelPriorityOrder, error) { + result := ChannelPriorityOrder{Items: []ChannelPriority{}, Changes: []ChannelPriorityChange{}} + err := DB.Transaction(func(tx *gorm.DB) error { + query := tx.Model(&Channel{}).Select("id", "priority").Order("id") + if !move.Preview { + query = lockForUpdate(query) + } + if err := query.Find(&result.Items).Error; err != nil { + return err + } + if move.Revision == "" || move.Revision != channelPriorityRevision(result.Items) { + return ErrChannelPriorityConflict + } + changes, err := planChannelPriorityMove(result.Items, move) + if err != nil { + return err + } + result.Changes = changes + byID := make(map[int]*ChannelPriority, len(result.Items)) + for i := range result.Items { + byID[result.Items[i].ID] = &result.Items[i] + } + for _, change := range changes { + byID[change.ID].Priority = change.Priority + if move.Preview { + continue + } + if err := tx.Model(&Channel{}).Where("id = ?", change.ID).Update("priority", change.Priority).Error; err != nil { + return err + } + if err := tx.Model(&Ability{}).Where("channel_id = ?", change.ID).Update("priority", change.Priority).Error; err != nil { + return err + } + } + result.Revision = channelPriorityRevision(result.Items) + return nil + }) + return result, err +} + +// Equal-priority channels remain together. Opening a slot shifts the shorter +// contiguous run of occupied integer priorities, measured by affected channels. +func planChannelPriorityMove(items []ChannelPriority, move ChannelPriorityMove) ([]ChannelPriorityChange, error) { + changes := []ChannelPriorityChange{} + byID := make(map[int]ChannelPriority, len(items)) + for _, item := range items { + if item.value() < -maxSortablePriority || item.value() > maxSortablePriority { + return nil, ErrChannelPriorityRange + } + byID[item.ID] = item + } + if len(move.Restore) > 0 { + if move.ID != 0 || move.TargetID != 0 || move.Position != "" { + return nil, ErrChannelPriorityInvalid + } + seen := make(map[int]bool, len(move.Restore)) + for _, change := range move.Restore { + item, ok := byID[change.ID] + if !ok || seen[change.ID] || (change.Priority != nil && (*change.Priority < -maxSortablePriority || *change.Priority > maxSortablePriority)) { + return nil, ErrChannelPriorityInvalid + } + if !equalPriority(item.Priority, change.Previous) { + return nil, ErrChannelPriorityConflict + } + seen[change.ID] = true + if !equalPriority(item.Priority, change.Priority) { + changes = append(changes, change) + } + } + return changes, nil + } + item, ok := byID[move.ID] + target, targetOK := byID[move.TargetID] + if !ok || !targetOK || move.ID == move.TargetID || !slices.Contains([]string{"before", "after", "same"}, move.Position) { + return nil, ErrChannelPriorityInvalid + } + values := make(map[int]int64, len(items)) + for _, entry := range items { + values[entry.ID] = entry.value() + } + if move.Position == "same" { + values[item.ID] = target.value() + } else { + counts := make(map[int64]int) + for _, entry := range items { + if entry.ID != item.ID { + counts[entry.value()]++ + } + } + levels := make([]int64, 0, len(counts)) + for level := range counts { + levels = append(levels, level) + } + slices.SortFunc(levels, func(a, b int64) int { + if a > b { + return -1 + } + if a < b { + return 1 + } + return 0 + }) + index := slices.Index(levels, target.value()) + if move.Position == "after" { + index++ + } + hi, lo := maxSortablePriority+1, -maxSortablePriority-1 + if index > 0 { + hi = levels[index-1] + } + if index < len(levels) { + lo = levels[index] + } + // Dropping into the channel's existing slot must not renumber it. + if item.value() < hi && item.value() > lo { + return changes, nil + } + if hi-lo < 2 { + up, down := index-1, index + upCost, downCost := 0, 0 + for up >= 0 { + upCost += counts[levels[up]] + if (up == 0 && levels[up] < maxSortablePriority) || (up > 0 && levels[up-1]-levels[up] > 1) { + break + } + up-- + } + for down < len(levels) { + downCost += counts[levels[down]] + if (down == len(levels)-1 && levels[down] > -maxSortablePriority) || (down+1 < len(levels) && levels[down]-levels[down+1] > 1) { + break + } + down++ + } + canUp, canDown := up >= 0 && index > 0, down < len(levels) && index < len(levels) + if !canUp && !canDown { + return nil, ErrChannelPriorityRange + } + if canUp && (!canDown || upCost < downCost) { + for id, value := range values { + if id != item.ID && value >= levels[index-1] && value <= levels[up] { + values[id]++ + } + } + hi++ + } else { + for id, value := range values { + if id != item.ID && value <= levels[index] && value >= levels[down] { + values[id]-- + } + } + lo-- + } + } + value := lo + (hi-lo)/2 + if index == 0 { + value = min(maxSortablePriority, lo+10) + } else if index == len(levels) { + value = max(-maxSortablePriority, hi-10) + } + values[item.ID] = value + } + for _, entry := range items { + value := values[entry.ID] + if value != entry.value() { + changes = append(changes, ChannelPriorityChange{ID: entry.ID, Previous: entry.Priority, Priority: &value}) + } + } + return changes, nil +} + +func equalPriority(a, b *int64) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} diff --git a/model/channel_priority_test.go b/model/channel_priority_test.go new file mode 100644 index 0000000000..1bd2ebc73f --- /dev/null +++ b/model/channel_priority_test.go @@ -0,0 +1,209 @@ +package model + +import ( + "errors" + "os" + "sync" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/schema" +) + +func TestChannelPriorityPlan(t *testing.T) { + for _, tc := range []struct { + name string + values []int64 + id, target int + position string + want map[int]int64 + }{ + {"midpoint", []int64{100, 80, 0}, 3, 2, "before", map[int]int64{3: 90}}, + {"top preserves peers", []int64{0, 0, 0}, 3, 1, "before", map[int]int64{3: 10}}, + {"bottom", []int64{0, 0, 0}, 1, 3, "after", map[int]int64{1: -10}}, + {"same tier", []int64{100, 80, 0}, 3, 2, "same", map[int]int64{3: 80}}, + {"same slot", []int64{100, 80, 0}, 2, 1, "after", map[int]int64{}}, + {"same tier no op", []int64{0, 0}, 1, 2, "same", map[int]int64{}}, + {"shift smaller upper tier", []int64{100, 99, 99, 0}, 4, 2, "before", map[int]int64{1: 101, 4: 100}}, + {"shift smaller lower tier", []int64{100, 100, 99, 0}, 4, 3, "before", map[int]int64{3: 98, 4: 99}}, + {"shift complete adjacent tiers", []int64{102, 101, 100, 99, 0}, 5, 3, "after", map[int]int64{4: 98, 5: 99}}, + {"upper safe bound", []int64{maxSortablePriority, 0}, 2, 1, "before", map[int]int64{1: maxSortablePriority - 1, 2: maxSortablePriority}}, + {"lower safe bound", []int64{-maxSortablePriority, 0}, 2, 1, "after", map[int]int64{1: -maxSortablePriority + 1, 2: -maxSortablePriority}}, + } { + t.Run(tc.name, func(t *testing.T) { + items := make([]ChannelPriority, len(tc.values)) + for i, value := range tc.values { + items[i] = ChannelPriority{ID: i + 1, Priority: &value} + } + changes, err := planChannelPriorityMove(items, ChannelPriorityMove{ID: tc.id, TargetID: tc.target, Position: tc.position}) + require.NoError(t, err) + got := map[int]int64{} + for _, change := range changes { + require.NotNil(t, change.Priority) + got[change.ID] = *change.Priority + } + assert.Equal(t, tc.want, got) + }) + } + _, err := planChannelPriorityMove([]ChannelPriority{{ID: 1}, {ID: 2}}, ChannelPriorityMove{ID: 1, TargetID: 2, Position: "invalid"}) + assert.ErrorIs(t, err, ErrChannelPriorityInvalid) +} + +func TestChannelPriorityDatabaseMatrix(t *testing.T) { + for _, dialect := range []string{"sqlite", "mysql", "postgres"} { + t.Run(dialect, func(t *testing.T) { + var driver gorm.Dialector + switch dialect { + case "sqlite": + driver = sqlite.Open(":memory:") + case "mysql": + dsn := os.Getenv("TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("TEST_MYSQL_DSN not configured") + } + driver = mysql.Open(dsn) + case "postgres": + dsn := os.Getenv("TEST_POSTGRES_DSN") + if dsn == "" { + t.Skip("TEST_POSTGRES_DSN not configured") + } + driver = postgres.Open(dsn) + } + db, err := gorm.Open(driver, &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "priority_test_"}}) + require.NoError(t, err) + sqlDB, err := db.DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + previousDB, previousType := DB, common.MainDatabaseType() + DB = db + common.SetMainDatabaseType(common.DatabaseType(dialect)) + initCol() + t.Cleanup(func() { + require.NoError(t, db.Migrator().DropTable(&Ability{}, &Channel{})) + DB = previousDB + common.SetMainDatabaseType(previousType) + initCol() + require.NoError(t, sqlDB.Close()) + }) + require.NoError(t, db.AutoMigrate(&Channel{}, &Ability{})) + var version string + query := "SELECT version()" + if dialect == "sqlite" { + query = "SELECT sqlite_version()" + } + require.NoError(t, db.Raw(query).Scan(&version).Error) + t.Logf("database version: %s", version) + high, low, zero := int64(100), int64(99), int64(0) + w1, w2, w3, w4 := uint(11), uint(22), uint(33), uint(44) + channels := []Channel{ + {Name: "high", Priority: &high, Weight: &w1, Key: "preserved", Status: 1}, + {Name: "low", Priority: &low, Weight: &w2, Status: 1}, + {Name: "peer", Priority: &low, Weight: &w3, Status: 1}, + {Name: "moving", Priority: &zero, Weight: &w4, Status: 1}, + } + require.NoError(t, db.Create(&channels).Error) + for _, channel := range channels { + for _, group := range []string{"default", "second"} { + require.NoError(t, db.Create(&Ability{Group: group, Model: "test", ChannelId: channel.Id, Priority: channel.Priority, Weight: uint(*channel.Weight), Enabled: true}).Error) + } + } + initial, err := GetChannelPriorityOrder() + require.NoError(t, err) + move := ChannelPriorityMove{Revision: initial.Revision, ID: channels[3].Id, TargetID: channels[1].Id, Position: "before", Preview: true} + preview, err := ReorderChannelPriority(move) + require.NoError(t, err) + require.Len(t, preview.Changes, 2) + unchanged, err := GetChannelPriorityOrder() + require.NoError(t, err) + assert.Equal(t, initial, unchanged, "preview does not persist") + move.Preview = false + applied, err := ReorderChannelPriority(move) + require.NoError(t, err) + assert.Equal(t, preview, applied) + for _, change := range applied.Changes { + var abilities []Ability + require.NoError(t, db.Where("channel_id = ?", change.ID).Find(&abilities).Error) + require.Len(t, abilities, 2) + for _, ability := range abilities { + assert.Equal(t, change.Priority, ability.Priority) + assert.True(t, ability.Enabled) + } + } + var saved Channel + require.NoError(t, db.First(&saved, channels[0].Id).Error) + assert.Equal(t, "preserved", saved.Key) + assert.Equal(t, channels[0].Weight, saved.Weight) + _, err = ReorderChannelPriority(move) + assert.ErrorIs(t, err, ErrChannelPriorityConflict) + undo := ChannelPriorityMove{Revision: applied.Revision} + for _, change := range applied.Changes { + undo.Restore = append(undo.Restore, ChannelPriorityChange{ID: change.ID, Previous: change.Priority, Priority: change.Previous}) + } + restored, err := ReorderChannelPriority(undo) + require.NoError(t, err) + assert.Equal(t, initial.Items, restored.Items) + assert.Equal(t, initial.Revision, restored.Revision) + if dialect != "sqlite" { + sqlDB.SetMaxOpenConns(2) + } + start := make(chan struct{}) + errorsCh := make(chan error, 2) + var workers sync.WaitGroup + for range 2 { + workers.Go(func() { <-start; _, moveErr := ReorderChannelPriority(move); errorsCh <- moveErr }) + } + close(start) + workers.Wait() + close(errorsCh) + succeeded, conflicted := 0, 0 + for moveErr := range errorsCh { + if moveErr == nil { + succeeded++ + } else { + require.ErrorIs(t, moveErr, ErrChannelPriorityConflict) + conflicted++ + } + } + assert.Equal(t, 1, succeeded) + assert.Equal(t, 1, conflicted) + _, err = ReorderChannelPriority(undo) + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + // An ability write failure must roll back every channel change too. + require.NoError(t, db.Callback().Update().Before("gorm:update").Register("priority_test_failure", func(tx *gorm.DB) { + if tx.Statement.Table == "priority_test_abilities" { + tx.AddError(errors.New("forced routing write failure")) + } + })) + _, err = ReorderChannelPriority(move) + require.ErrorContains(t, err, "forced routing write failure") + require.NoError(t, db.Callback().Update().Remove("priority_test_failure")) + unchanged, err = GetChannelPriorityOrder() + require.NoError(t, err) + assert.Equal(t, initial, unchanged) + // Null priority is an effective zero and undo must restore the null itself. + require.NoError(t, db.Model(&Channel{}).Where("id = ?", channels[3].Id).Update("priority", nil).Error) + nullable, err := GetChannelPriorityOrder() + require.NoError(t, err) + var sorted []ChannelPriority + require.NoError(t, NewChannelSortOptions("priority", "asc", false).Apply(db.Model(&Channel{}).Select("id", "priority")).Find(&sorted).Error) + assert.Equal(t, []int{channels[3].Id, channels[2].Id, channels[1].Id, channels[0].Id}, []int{sorted[0].ID, sorted[1].ID, sorted[2].ID, sorted[3].ID}) + move.Revision = nullable.Revision + applied, err = ReorderChannelPriority(move) + require.NoError(t, err) + undo = ChannelPriorityMove{Revision: applied.Revision} + for _, change := range applied.Changes { + undo.Restore = append(undo.Restore, ChannelPriorityChange{ID: change.ID, Previous: change.Priority, Priority: change.Previous}) + } + restored, err = ReorderChannelPriority(undo) + require.NoError(t, err) + assert.Equal(t, nullable.Items, restored.Items) + }) + } +} diff --git a/router/channel-router.go b/router/channel-router.go index 560f5274dc..e4cd4bdb53 100644 --- a/router/channel-router.go +++ b/router/channel-router.go @@ -55,6 +55,8 @@ func registerChannelRoutes(apiRouter *gin.RouterGroup) { } var channelPermissionRoutes = []permissionRoute{ + {method: http.MethodGet, path: "/priority/order", permission: authz.ChannelRead, handler: controller.GetChannelPriorityOrder}, + {method: http.MethodPost, path: "/priority/reorder", permission: authz.ChannelWrite, handler: controller.ReorderChannelPriority}, {method: http.MethodGet, path: "/", permission: authz.ChannelRead, handler: controller.GetAllChannels}, {method: http.MethodGet, path: "/search", permission: authz.ChannelRead, handler: controller.SearchChannels}, {method: http.MethodGet, path: "/models", permission: authz.ChannelRead, handler: controller.ChannelListModels}, diff --git a/router/channel_router_test.go b/router/channel_router_test.go index cd89771345..3e51ffa503 100644 --- a/router/channel_router_test.go +++ b/router/channel_router_test.go @@ -25,6 +25,8 @@ func TestChannelDefaultBaseURLsRequireReadPermission(t *testing.T) { } func TestChannelStatusRoutesUseExpectedPermissions(t *testing.T) { + assertChannelRoutePermission(t, http.MethodGet, "/priority/order", authz.ChannelRead, controller.GetChannelPriorityOrder) + assertChannelRoutePermission(t, http.MethodPost, "/priority/reorder", authz.ChannelWrite, controller.ReorderChannelPriority) assertChannelRoutePermission(t, http.MethodGet, "/:id/vllm/status", authz.ChannelRead, controller.GetVLLMChannelStatus) assertChannelRoutePermission(t, http.MethodGet, "/:id/sglang/status", authz.ChannelRead, controller.GetSGLangChannelStatus) assertChannelRoutePermission(t, http.MethodPost, "/:id/status", authz.ChannelOperate, controller.UpdateChannelStatus) diff --git a/web/src/components/data-table/core/data-table-row.tsx b/web/src/components/data-table/core/data-table-row.tsx index 4777f9a632..705dbebd42 100644 --- a/web/src/components/data-table/core/data-table-row.tsx +++ b/web/src/components/data-table/core/data-table-row.tsx @@ -63,6 +63,7 @@ function DataTableRowInner({ return ( (props: DataTableCardGridProps) {
{actions != null && ( -
+
{actions}
)} diff --git a/web/src/features/channels/api.ts b/web/src/features/channels/api.ts index 463a2166a8..90cab36ec2 100644 --- a/web/src/features/channels/api.ts +++ b/web/src/features/channels/api.ts @@ -20,6 +20,7 @@ import { getGroups as getUserGroups } from '@/features/users/api' import { api, type ApiRequestConfig } from '@/lib/api' import { requireServerSuccess } from '@/lib/server-error-message' +import type { PriorityMove, PriorityOrder } from './lib/channel-priority-order' import type { InferenceStatus } from './lib/inference-status' import type { AddChannelRequest, @@ -50,6 +51,24 @@ const channelActionConfig = ( skipErrorHandler: true, }) +export async function getChannelPriorityOrder(): Promise { + const response = await api.get<{ success: boolean; data: PriorityOrder }>( + '/api/channel/priority/order', + { disableDuplicate: true } + ) + return requireServerSuccess(response.data).data +} + +export async function reorderChannelPriority( + move: PriorityMove +): Promise { + const response = await api.post<{ success: boolean; data: PriorityOrder }>( + '/api/channel/priority/reorder', + move + ) + return requireServerSuccess(response.data).data +} + export async function getInferenceStatus( channelId: number, provider: 'vllm' | 'sglang', diff --git a/web/src/features/channels/components/__tests__/priority-order.test.tsx b/web/src/features/channels/components/__tests__/priority-order.test.tsx new file mode 100644 index 0000000000..7c5e90e8e2 --- /dev/null +++ b/web/src/features/channels/components/__tests__/priority-order.test.tsx @@ -0,0 +1,728 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RouterProvider, +} from '@tanstack/react-router' +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, expect, test, vi } from 'vitest' + +import { api } from '@/lib/api' +import { useAuthStore } from '@/stores/auth-store' + +import type { + PriorityMove, + PriorityOrder, +} from '../../lib/channel-priority-order' +import type { Channel } from '../../types' +import { ChannelsPrimaryButtons } from '../channels-primary-buttons' +import { ChannelsProvider } from '../channels-provider' +import { ChannelsTable } from '../channels-table' + +const initial: PriorityOrder = { + revision: 'initial', + items: [ + { id: 1, priority: 100 }, + { id: 2, priority: 80 }, + { id: 3, priority: 0 }, + ], + changes: [], +} +const applied: PriorityOrder = { + revision: 'applied', + items: [ + { id: 1, priority: 100 }, + { id: 2, priority: 80 }, + { id: 3, priority: 90 }, + ], + changes: [{ id: 3, previous: 0, priority: 90 }], +} +let current: PriorityOrder +let requests: PriorityMove[] +let failSave: boolean +const pointerMethods = [ + 'setPointerCapture', + 'releasePointerCapture', + 'hasPointerCapture', +] as const +const originalPointerMethods = pointerMethods.map( + (name) => + [ + name, + Object.getOwnPropertyDescriptor(HTMLElement.prototype, name), + ] as const +) +afterEach(() => { + vi.unstubAllGlobals() + for (const [name, descriptor] of originalPointerMethods) { + if (descriptor) { + Object.defineProperty(HTMLElement.prototype, name, descriptor) + } else Reflect.deleteProperty(HTMLElement.prototype, name) + } + localStorage.clear() +}) + +function channel(item: { id: number; priority: number | null }): Channel { + return { + id: item.id, + priority: item.priority, + name: `Channel ${item.id}`, + type: 1, + status: 1, + group: 'default', + models: 'test-model', + weight: 10, + balance: 0, + response_time: 0, + test_time: 0, + channel_info: {}, + } as Channel +} + +beforeEach(() => { + current = initial + requests = [] + failSave = false + localStorage.clear() + useAuthStore.getState().auth.setUser({ + id: 1, + username: 'test', + display_name: 'Test', + role: 100, + group: 'default', + }) + vi.spyOn(api, 'get').mockImplementation(async (url, config) => { + if (url === '/api/channel/priority/order') { + return { data: { success: true, data: current } } + } + if (url === '/api/channel' || url === '/api/channel/search') { + const field = config?.params?.sort_by ?? 'priority' + const desc = config?.params?.sort_order !== 'asc' + const items = current.items + .map(channel) + .sort( + (a, b) => + ((Number(a[field as keyof Channel]) || 0) - + (Number(b[field as keyof Channel]) || 0)) * + (desc ? -1 : 1) || b.id - a.id + ) + return { + data: { + success: true, + data: { items, total: 3, type_counts: { 1: 3 } }, + }, + } + } + if (url === '/api/group/') { + return { data: { success: true, data: ['default'] } } + } + return { data: { success: true, data: [] } } + }) + vi.spyOn(api, 'post').mockImplementation(async (url, body) => { + if (url !== '/api/channel/priority/reorder') { + return { data: { success: true } } + } + const move = body as PriorityMove + requests.push(move) + if (failSave && !move.preview) { + return { + data: { + success: false, + message: 'Channel priorities changed. Refresh and try again.', + }, + } + } + const joined: PriorityOrder = { + revision: 'joined', + items: [ + { id: 1, priority: 100 }, + { id: 2, priority: 80 }, + { id: 3, priority: 80 }, + ], + changes: [{ id: 3, previous: 0, priority: 80 }], + } + let result = applied + if (move.restore) result = initial + else if (move.position === 'same') result = joined + if (!move.preview) current = result + return { data: { success: true, data: result } } + }) +}) + +async function mount(view: 'table' | 'card' = 'card', enabled = true) { + localStorage.setItem('channels:view-mode', view) + const root = createRootRoute() + const authenticated = createRoute({ + getParentRoute: () => root, + id: '_authenticated', + }) + const channels = createRoute({ + getParentRoute: () => authenticated, + path: '/channels/', + validateSearch: (search) => ({ page: 1, pageSize: 10, ...search }), + component: () => ( + +
+ +
+ +
+ ), + }) + const router = createRouter({ + routeTree: root.addChildren([authenticated.addChildren([channels])]), + history: createMemoryHistory({ initialEntries: ['/channels/'] }), + }) + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + render( + + + + ) + await screen.findByRole('button', { name: 'Drag mode' }) + if (enabled) { + await waitFor(() => + expect(screen.getByRole('button', { name: 'Drag mode' })).toBeEnabled() + ) + await userEvent + .setup() + .click(screen.getByRole('button', { name: 'Drag mode' })) + await screen.findByRole('button', { name: 'Reorder channel 3' }) + } + return { client, router } +} + +function orderInView() { + return [...document.querySelectorAll('[data-sortable-id]')].map( + (element) => element.dataset.sortableId + ) +} + +test.each(['table', 'card'] as const)( + '%s keyboard reorder saves priority and undo restores it', + async (view) => { + await mount(view) + const user = userEvent.setup() + fireEvent.keyDown( + screen.getByRole('button', { name: 'Reorder channel 3' }), + { key: 'ArrowUp' } + ) + await screen.findByRole('button', { name: 'Undo' }) + await waitFor(() => + expect(orderInView()).toEqual(['channel:1', 'channel:3', 'channel:2']) + ) + expect(requests.filter((request) => !request.preview)).toEqual([ + { id: 3, target_id: 2, position: 'before', revision: 'initial' }, + ]) + await user.click(screen.getByRole('button', { name: 'Undo' })) + await waitFor(() => + expect(orderInView()).toEqual(['channel:1', 'channel:2', 'channel:3']) + ) + expect(requests.at(-1)).toEqual({ + revision: 'applied', + restore: [{ id: 3, previous: 90, priority: 0 }], + }) + } +) + +test('switching sort exits reorder mode without automatically reopening it', async () => { + await mount() + const user = userEvent.setup() + await user.click(screen.getByRole('combobox', { name: 'Sort channels by' })) + await user.click(await screen.findByRole('option', { name: /^ID$/ })) + await waitFor(() => + expect(screen.getByRole('button', { name: 'Drag mode' })).toBeDisabled() + ) + expect( + screen.queryByRole('button', { name: 'Reorder channel 3' }) + ).not.toBeInTheDocument() + await user.click(screen.getByRole('combobox', { name: 'Sort channels by' })) + await user.click(await screen.findByRole('option', { name: /^Priority$/ })) + await waitFor(() => + expect(screen.getByRole('button', { name: 'Drag mode' })).toBeEnabled() + ) + expect( + screen.queryByRole('button', { name: 'Reorder channel 3' }) + ).not.toBeInTheDocument() + expect(requests).toEqual([]) +}) + +test('failed save restores the visible order and does not offer undo', async () => { + failSave = true + await mount() + fireEvent.keyDown(screen.getByRole('button', { name: 'Reorder channel 3' }), { + key: 'ArrowUp', + }) + await waitFor(() => expect(requests).toHaveLength(1)) + await waitFor(() => + expect( + screen.getByRole('button', { name: 'Reorder channel 3' }) + ).toBeEnabled() + ) + expect(orderInView()).toEqual(['channel:1', 'channel:2', 'channel:3']) + expect(screen.queryByRole('button', { name: 'Undo' })).not.toBeInTheDocument() +}) + +test('card and table views share the saved order', async () => { + await mount() + const user = userEvent.setup() + fireEvent.keyDown(screen.getByRole('button', { name: 'Reorder channel 3' }), { + key: 'ArrowUp', + }) + await screen.findByRole('button', { name: 'Undo' }) + const views = within(screen.getByRole('group', { name: 'View mode' })) + views.getByRole('button', { name: 'Card view' }).focus() + await user.tab() + expect(views.getByRole('button', { name: 'Table view' })).toHaveFocus() + await user.keyboard('{Enter}') + await screen.findByRole('table') + expect(views.getByRole('button', { name: 'Table view' })).toHaveAttribute( + 'aria-pressed', + 'true' + ) + expect(views.getByRole('button', { name: 'Card view' })).toHaveAttribute( + 'aria-pressed', + 'false' + ) + expect(orderInView()).toEqual(['channel:1', 'channel:3', 'channel:2']) +}) + +test('mobile view controls stay available with the filter panel collapsed', async () => { + const original = window.matchMedia + vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({ + ...original(query), + matches: query === '(max-width: 640px)', + })) + await mount('card', false) + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: 'Collapse' })) + expect( + screen.queryByPlaceholderText('Filter by name, ID, or key...') + ).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Refresh' })).toBeVisible() + expect(screen.getByRole('button', { name: 'Drag mode' })).toBeVisible() + await user.click(screen.getByRole('button', { name: 'Table view' })) + expect(await screen.findByRole('table')).toBeVisible() + await user.click(screen.getByRole('button', { name: 'View' })) + expect( + await screen.findByRole('menuitemcheckbox', { name: 'Weight' }) + ).toBeVisible() +}) + +function pointerGeometry() { + class TestPointerEvent extends MouseEvent { + pointerId: number + constructor(type: string, init: PointerEventInit) { + super(type, init) + this.pointerId = init.pointerId ?? 1 + } + } + vi.stubGlobal('PointerEvent', TestPointerEvent) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + function (this: HTMLElement) { + if (this.dataset.sortableId) { + const index = [ + ...document.querySelectorAll('[data-sortable-id]'), + ].indexOf(this) + if (this.tagName === 'TR') { + return new DOMRect(0, 100 + index * 60, 1000, 60) + } + return new DOMRect( + (index % 3) * 320, + 100 + Math.floor(index / 3) * 220, + 300, + 200 + ) + } + return new DOMRect(0, 0, 1200, 800) + } + ) + for (const name of [ + 'setPointerCapture', + 'releasePointerCapture', + 'hasPointerCapture', + ]) { + Object.defineProperty(HTMLElement.prototype, name, { + configurable: true, + value: () => false, + }) + } +} + +test('a quick card pointer drag saves even when released before an animation frame', async () => { + await mount() + pointerGeometry() + const handle = screen.getByRole('button', { name: 'Reorder channel 3' }) + const scope = screen.getByRole('region', { name: 'Channels' }) + fireEvent.pointerDown(handle, { + pointerId: 1, + button: 0, + clientX: 660, + clientY: 130, + }) + fireEvent.pointerMove(scope, { pointerId: 1, clientX: 340, clientY: 150 }) + fireEvent.pointerUp(scope, { pointerId: 1, clientX: 340, clientY: 150 }) + fireEvent.lostPointerCapture(scope, { pointerId: 1 }) + await screen.findByRole('button', { name: 'Undo' }) + expect(requests.filter((request) => !request.preview)).toEqual([ + { id: 3, target_id: 2, position: 'before', revision: 'initial' }, + ]) + await waitFor(() => + expect(document.querySelector('[data-origin-x]')).not.toBeInTheDocument() + ) + vi.unstubAllGlobals() +}) + +test('Escape cancels a card drag without saving and removes its ghost', async () => { + await mount() + pointerGeometry() + const scope = screen.getByRole('region', { name: 'Channels' }) + fireEvent.pointerDown( + screen.getByRole('button', { name: 'Reorder channel 3' }), + { pointerId: 1, button: 0, clientX: 660, clientY: 130 } + ) + fireEvent.pointerMove(scope, { pointerId: 1, clientX: 340, clientY: 150 }) + await waitFor(() => + expect(document.querySelector('[data-origin-x]')).toBeInTheDocument() + ) + fireEvent.keyDown(window, { key: 'Escape' }) + await waitFor(() => + expect(document.querySelector('[data-origin-x]')).not.toBeInTheDocument() + ) + expect(requests.filter((request) => !request.preview)).toEqual([]) + expect(orderInView()).toEqual(['channel:1', 'channel:2', 'channel:3']) + vi.unstubAllGlobals() +}) + +test('moving from a card edge to its center joins the tier despite the moving placeholder', async () => { + await mount() + pointerGeometry() + const scope = screen.getByRole('region', { name: 'Channels' }) + fireEvent.pointerDown( + screen.getByRole('button', { name: 'Reorder channel 3' }), + { pointerId: 1, button: 0, clientX: 660, clientY: 130 } + ) + fireEvent.pointerMove(scope, { pointerId: 1, clientX: 340, clientY: 150 }) + await waitFor(() => + expect(document.querySelector('[data-origin-x]')).toBeInTheDocument() + ) + expect(orderInView()).toEqual(['channel:1', 'channel:2', 'channel:3']) + expect(requests.every((request) => request.preview)).toBe(true) + fireEvent.pointerMove(scope, { pointerId: 1, clientX: 470, clientY: 150 }) + fireEvent.pointerUp(scope, { pointerId: 1, clientX: 470, clientY: 150 }) + await screen.findByRole('button', { name: 'Undo' }) + expect(requests.filter((request) => !request.preview)).toEqual([ + { id: 3, target_id: 2, position: 'same', revision: 'initial' }, + ]) + await waitFor(() => + expect(document.querySelector('[data-origin-x]')).not.toBeInTheDocument() + ) + vi.unstubAllGlobals() +}) + +test('tag grouping disables reorder mode', async () => { + localStorage.setItem('enable-tag-mode', 'true') + await mount('card', false) + expect( + screen.getByRole('button', { + name: 'Drag mode', + }) + ).toBeDisabled() + expect(requests).toEqual([]) +}) + +test.each(['table', 'card'] as const)( + '%s drag handles are hidden by default', + async (view) => { + await mount(view, false) + expect( + screen.queryByRole('button', { name: 'Reorder channel 3' }) + ).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Drag mode' })).toHaveAttribute( + 'aria-pressed', + 'false' + ) + } +) + +test.each(['table', 'card'] as const)( + '%s reordering can be explicitly enabled and finished', + async (view) => { + await mount(view, false) + const user = userEvent.setup() + screen.getByRole('button', { name: 'Drag mode' }).focus() + await user.keyboard('{Enter}') + expect( + screen.getByRole('button', { name: 'Reorder channel 3' }) + ).toBeEnabled() + expect( + screen.getByRole('button', { name: 'Finish reordering' }) + ).toHaveAttribute('aria-pressed', 'true') + await user.click(screen.getByRole('button', { name: 'Finish reordering' })) + expect( + screen.queryByRole('button', { name: 'Reorder channel 3' }) + ).not.toBeInTheDocument() + expect(requests).toEqual([]) + expect(api.get).not.toHaveBeenCalledWith('/api/channel/priority/order') + } +) + +test('the page header provides the single sorting control without an ID switch', async () => { + await mount('card', false) + expect( + screen.queryByRole('switch', { name: 'Sort by ID' }) + ).not.toBeInTheDocument() + expect( + screen.getAllByRole('combobox', { name: 'Sort channels by' }) + ).toHaveLength(1) + expect( + within(screen.getByRole('banner')).getByRole('combobox', { + name: 'Sort channels by', + }) + ).toBeEnabled() + expect( + within(screen.getByRole('region', { name: 'Channels' })).queryByRole( + 'combobox', + { name: 'Sort channels by' } + ) + ).not.toBeInTheDocument() +}) + +test('the header places reorder before batch mode while shared display controls stay in the table toolbar', async () => { + await mount('card', false) + const user = userEvent.setup() + const header = within(screen.getByRole('banner')) + const toolbar = within(screen.getByRole('region', { name: 'Channels' })) + const reorder = header.getByRole('button', { name: 'Drag mode' }) + reorder.focus() + await user.tab() + expect(header.getByRole('switch', { name: 'Batch Operations' })).toHaveFocus() + expect( + toolbar.queryByRole('button', { name: 'Drag mode' }) + ).not.toBeInTheDocument() + expect(toolbar.getByRole('button', { name: 'Hide' })).toBeVisible() + expect(toolbar.getByRole('group', { name: 'View mode' })).toBeVisible() +}) + +test('the header honors the saved ID preference and changes the shared sort direction', async () => { + localStorage.setItem('channels-id-sort', 'true') + await mount('card', false) + const header = within(screen.getByRole('banner')) + expect( + header.getByRole('combobox', { name: 'Sort channels by' }) + ).toHaveTextContent('ID') + await waitFor(() => + expect(orderInView()).toEqual(['channel:3', 'channel:2', 'channel:1']) + ) + const sort = within(header.getByRole('group', { name: 'Sort channels by' })) + sort.getByRole('combobox', { name: 'Sort channels by' }).focus() + const user = userEvent.setup() + await user.tab() + expect(sort.getByRole('button', { name: 'Descending' })).toHaveFocus() + await user.keyboard('{Enter}') + await waitFor(() => + expect(orderInView()).toEqual(['channel:1', 'channel:2', 'channel:3']) + ) + expect(header.getByRole('button', { name: 'Ascending' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'Drag mode' })).toBeDisabled() +}) + +test('the compact more-actions button opens by keyboard and restores focus on Escape', async () => { + await mount('card', false) + const user = userEvent.setup() + const more = within(screen.getByRole('banner')).getByRole('button', { + name: 'More actions', + }) + more.focus() + await user.keyboard('{Enter}') + expect(more).toHaveAttribute('aria-expanded', 'true') + expect( + await screen.findByRole('menuitem', { name: 'Test All Channels' }) + ).toBeVisible() + await user.keyboard('{Escape}') + await waitFor(() => expect(more).toHaveFocus()) + expect(more).toHaveAttribute('aria-expanded', 'false') +}) + +test('sorting a table column updates the header selection and remains active in card view', async () => { + await mount('table', false) + const user = userEvent.setup() + await user.click( + within(screen.getByRole('table')).getByRole('button', { + name: 'Name', + }) + ) + await user.click(await screen.findByRole('menuitem', { name: 'Asc' })) + const header = within(screen.getByRole('banner')) + expect( + header.getByRole('combobox', { name: 'Sort channels by' }) + ).toHaveTextContent('Name') + expect(header.getByRole('button', { name: 'Ascending' })).toBeEnabled() + await user.click(screen.getByRole('button', { name: 'Card view' })) + expect( + header.getByRole('combobox', { name: 'Sort channels by' }) + ).toHaveTextContent('Name') + expect(screen.getByRole('button', { name: 'Drag mode' })).toBeDisabled() +}) + +test('dropping onto a card that moved into the original slot joins its priority tier', async () => { + await mount() + pointerGeometry() + const scope = screen.getByRole('region', { name: 'Channels' }) + fireEvent.pointerDown( + screen.getByRole('button', { name: 'Reorder channel 3' }), + { pointerId: 1, button: 0, clientX: 660, clientY: 130 } + ) + fireEvent.pointerMove(scope, { pointerId: 1, clientX: 20, clientY: 150 }) + await waitFor(() => + expect(document.querySelector('[data-origin-x]')).toBeInTheDocument() + ) + // Channel 2 now occupies the last slot that channel 3 originally occupied. + fireEvent.pointerMove(scope, { pointerId: 1, clientX: 790, clientY: 150 }) + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent( + 'Join the same priority tier' + ) + ) + expect( + document.querySelector( + '[data-origin-x] [data-column-id="priority"] [data-slot="numeric-spinner-value"]' + ) + ).toHaveTextContent('80') + expect( + document.querySelector('[data-sortable-id="channel:2"]') + ).toHaveAttribute('data-priority-drop', 'same') + fireEvent.pointerUp(scope, { pointerId: 1, clientX: 790, clientY: 150 }) + await screen.findByRole('button', { name: 'Undo' }) + expect(requests.filter((request) => !request.preview)).toEqual([ + { id: 3, target_id: 2, position: 'same', revision: 'initial' }, + ]) +}) + +test.each(['table', 'card'] as const)( + '%s keeps a held tier preview until Escape cancels it', + async (view) => { + await mount(view) + pointerGeometry() + const scope = screen.getByRole('region', { name: 'Channels' }) + const source = document.querySelector( + '[data-sortable-id="channel:3"]' + ) as HTMLElement + const start = + view === 'table' + ? { clientX: 20, clientY: 250 } + : { clientX: 660, clientY: 130 } + const target = + view === 'table' + ? { clientX: 20, clientY: 190 } + : { clientX: 470, clientY: 150 } + fireEvent.pointerDown( + screen.getByRole('button', { name: 'Reorder channel 3' }), + { pointerId: 1, button: 0, ...start } + ) + fireEvent.pointerMove(scope, { pointerId: 1, ...target }) + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent( + 'Join the same priority tier' + ) + ) + expect(source.isConnected).toBe(true) + expect(source).toHaveStyle({ opacity: '0.2' }) + expect( + document.querySelector( + '[data-origin-x] [data-column-id="priority"] [data-slot="numeric-spinner-value"]' + ) + ).toHaveTextContent('80') + expect( + document.querySelector('[data-sortable-id="channel:2"]') + ).toHaveAttribute('data-priority-drop', 'same') + fireEvent.keyDown(window, { key: 'Escape' }) + await waitFor(() => + expect(document.querySelector('[data-origin-x]')).not.toBeInTheDocument() + ) + expect( + document.querySelector('[data-priority-drop]') + ).not.toBeInTheDocument() + expect(source.style.opacity).toBe('') + expect(source.style.transform).toBe('') + expect(requests.filter((request) => !request.preview)).toEqual([]) + } +) + +test.each(['table', 'card'] as const)( + '%s distinguishes inserting a new tier from joining the target tier before release', + async (view) => { + await mount(view) + pointerGeometry() + const scope = screen.getByRole('region', { name: 'Channels' }) + const start = + view === 'table' + ? { clientX: 20, clientY: 250 } + : { clientX: 660, clientY: 130 } + const edge = + view === 'table' + ? { clientX: 20, clientY: 165 } + : { clientX: 340, clientY: 150 } + const center = + view === 'table' + ? { clientX: 20, clientY: 190 } + : { clientX: 470, clientY: 150 } + fireEvent.pointerDown( + screen.getByRole('button', { name: 'Reorder channel 3' }), + { pointerId: 1, button: 0, ...start } + ) + fireEvent.pointerMove(scope, { pointerId: 1, ...edge }) + await screen.findByText('Release to insert a new priority tier') + expect(document.querySelector('[data-priority-insertion]')).toBeVisible() + expect( + document.querySelector('[data-priority-drop="same"]') + ).not.toBeInTheDocument() + await screen.findByText('New priority: 90') + fireEvent.pointerMove(scope, { pointerId: 1, ...center }) + await screen.findByText('Release to join priority 80') + expect( + screen.queryByText('Release to insert a new priority tier') + ).not.toBeInTheDocument() + expect( + document.querySelector('[data-priority-insertion]') + ).not.toBeVisible() + expect( + document.querySelector('[data-sortable-id="channel:2"]') + ).toHaveAttribute('data-priority-drop', 'same') + fireEvent.keyDown(window, { key: 'Escape' }) + await waitFor(() => + expect( + screen.queryByText('Release to join priority 80') + ).not.toBeInTheDocument() + ) + } +) diff --git a/web/src/features/channels/components/__tests__/table-refresh.test.tsx b/web/src/features/channels/components/__tests__/table-refresh.test.tsx index b9b7c48d4b..bda5529ae6 100644 --- a/web/src/features/channels/components/__tests__/table-refresh.test.tsx +++ b/web/src/features/channels/components/__tests__/table-refresh.test.tsx @@ -144,7 +144,8 @@ it('refetches the channel list with the current filters and marks Refresh busy w gate = new Promise((resolve) => { release = resolve }) - await userEvent.click(refresh) + refresh.focus() + await userEvent.setup().keyboard('{Enter}') await waitFor(() => expect(searchCalls()).toHaveLength(2)) expect(searchCalls()[1][1]?.params).toEqual(searchCalls()[0][1]?.params) diff --git a/web/src/features/channels/components/channel-card.tsx b/web/src/features/channels/components/channel-card.tsx index 3f0b3d4c79..e123eeeba6 100644 --- a/web/src/features/channels/components/channel-card.tsx +++ b/web/src/features/channels/components/channel-card.tsx @@ -42,9 +42,11 @@ const SENSITIVE_MASK = '••••' function ChannelCardComponent({ row, isSelected, + reorderEnabled = false, }: { row: Row isSelected: boolean + reorderEnabled?: boolean }) { const { t } = useTranslation() const { sensitiveVisible } = useChannels() @@ -61,6 +63,7 @@ function ChannelCardComponent({ const groups = parseGroupsList(row.original.group ?? '') + const reorderCell = reorderEnabled ? renderCell('reorder') : null const selectCell = renderCell('select') const typeCell = renderCell('type') const nameCell = renderCell('name') @@ -91,6 +94,7 @@ function ChannelCardComponent({ {/* Row 1: selection + type, with status badge + actions menu */}
+ {reorderCell} {!isTagRow && selectCell && ( {selectCell} )} @@ -114,7 +118,10 @@ function ChannelCardComponent({ )} {nameCell}
-
+
{t('Priority')} {priorityCell}
diff --git a/web/src/features/channels/components/channel-priority-context.ts b/web/src/features/channels/components/channel-priority-context.ts new file mode 100644 index 0000000000..4f9ec9ab2a --- /dev/null +++ b/web/src/features/channels/components/channel-priority-context.ts @@ -0,0 +1,29 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { createContext, type KeyboardEvent, type PointerEvent } from 'react' + +type PriorityControls = { + reason: 'sort' | 'tag' | 'permission' | 'busy' | null + start: (event: PointerEvent, id: number) => void + keyDown: (event: KeyboardEvent, id: number) => void +} + +export const ChannelPriorityContext = createContext( + null +) diff --git a/web/src/features/channels/components/channel-priority-controls.tsx b/web/src/features/channels/components/channel-priority-controls.tsx new file mode 100644 index 0000000000..3f01a3b0af --- /dev/null +++ b/web/src/features/channels/components/channel-priority-controls.tsx @@ -0,0 +1,233 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { ArrowDown, ArrowUp, GripVertical, Undo2 } from 'lucide-react' +import { useContext, useSyncExternalStore } from 'react' +import { useTranslation } from 'react-i18next' + +import { Button } from '@/components/ui/button' +import { ButtonGroup } from '@/components/ui/button-group' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { toIntlLocale } from '@/i18n/languages' +import { formatNumber } from '@/lib/format' + +import type { useChannelPriorityOrder } from '../hooks/use-channel-priority-order' +import type { ChannelSortBy } from '../types' +import { ChannelPriorityContext } from './channel-priority-context' + +export function ChannelPriorityHandle(props: { id: number }) { + const controls = useContext(ChannelPriorityContext) + const { t } = useTranslation() + if (!controls) return null + let hint = t( + 'Drag to change priority. Use arrow keys to move, Escape to cancel.' + ) + if (controls.reason === 'sort') hint = t('Sort by priority to drag channels') + if (controls.reason === 'tag') { + hint = t('Disable tag grouping to drag channels') + } + if (controls.reason === 'permission') { + hint = t('You do not have permission to edit channels') + } + if (controls.reason === 'busy') hint = t('Saving...') + return ( + + }> + + + {hint} + + ) +} + +export function ChannelSortControl(props: { + field: ChannelSortBy + desc: boolean + disabled?: boolean + onChange: (field: ChannelSortBy, desc: boolean) => void +}) { + const { t } = useTranslation() + const options = [ + { value: 'priority', label: t('Priority') }, + { value: 'id', label: t('ID') }, + { value: 'name', label: t('Name') }, + { value: 'balance', label: t('Balance') }, + { value: 'response_time', label: t('Response') }, + { value: 'test_time', label: t('Last Tested') }, + ] + return ( + + + + + ) +} + +export function ChannelPriorityModeToggle(props: { + active: boolean + reason: 'sort' | 'tag' | 'permission' | 'busy' | null + onChange: () => void +}) { + const { t } = useTranslation() + let hint = t('Drag mode') + if (props.reason === 'sort') hint = t('Sort by priority to drag channels') + if (props.reason === 'tag') hint = t('Disable tag grouping to drag channels') + if (props.reason === 'permission') { + hint = t('You do not have permission to edit channels') + } + if (props.reason === 'busy') hint = t('Saving...') + return ( + + }> + + + {hint} + + ) +} + +export function ChannelPriorityStatus(props: { + order: ReturnType +}) { + const { t, i18n } = useTranslation() + const order = props.order + const hover = useSyncExternalStore( + order.previewStore.subscribe, + order.previewStore.getSnapshot + ) + const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language) + let message = '' + if (order.saving) message = t('Saving...') + else if (hover.preparing) message = t('Calculating priority...') + else if (hover.preview && hover.drop) { + const value = + hover.preview.items.find((item) => item.id === hover.drop?.id) + ?.priority ?? 0 + const visible = new Set(order.items.map((item) => item.id)) + const hidden = hover.preview.changes.filter( + (item) => !visible.has(item.id) + ).length + message = t('New priority: {{priority}} · {{count}} channels affected', { + priority: formatNumber(value, locale), + count: hover.preview.changes.length, + }) + if (hidden) { + message += ` · ${t('{{count}} outside this view', { count: hidden })}` + } + if (hover.drop.position === 'same') { + message += ` · ${t('Join the same priority tier')}` + } else { + message += ` · ${t('Release to insert a new priority tier')}` + } + } else if (order.active) { + message = t( + 'Drop between tiers to change priority, or in the center to join a tier' + ) + } + if (!message && !order.undo?.changes.length) return null + return ( +
+ + {message || t('Channel priorities saved')} + + {!!order.undo?.changes.length && ( + + )} +
+ ) +} diff --git a/web/src/features/channels/components/channel-priority-drop-hint.tsx b/web/src/features/channels/components/channel-priority-drop-hint.tsx new file mode 100644 index 0000000000..468b01f0a2 --- /dev/null +++ b/web/src/features/channels/components/channel-priority-drop-hint.tsx @@ -0,0 +1,86 @@ +/* +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 . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { ArrowDownToLine, Layers } from 'lucide-react' +import { useSyncExternalStore } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' + +import { Badge } from '@/components/ui/badge' +import { toIntlLocale } from '@/i18n/languages' +import { formatNumber } from '@/lib/format' + +import type { useChannelPriorityOrder } from '../hooks/use-channel-priority-order' +import type { Channel } from '../types' + +export function ChannelPriorityDropHint(props: { + container: HTMLElement | null + order: ReturnType + channels: Channel[] +}) { + const { t, i18n } = useTranslation() + const hover = useSyncExternalStore( + props.order.previewStore.subscribe, + props.order.previewStore.getSnapshot + ) + if (!props.container || !hover.drop) return null + const joining = hover.drop.position === 'same' + const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language) + const targetPriority = + props.channels.find((item) => item.id === hover.drop?.target_id) + ?.priority ?? 0 + const priority = + hover.preview?.items.find((item) => item.id === hover.drop?.id)?.priority ?? + targetPriority + let detail = t('Use the same priority as the highlighted channels') + if (!joining) { + detail = hover.preview + ? t('New priority: {{priority}}', { + priority: formatNumber(priority, locale), + }) + : t('Calculating priority...') + } + return createPortal( +
+ + {joining ? ( + + ) : ( + + )} + + {joining + ? t('Release to join priority {{priority}}', { + priority: formatNumber(priority, locale), + }) + : t('Release to insert a new priority tier')} + + +

+ {detail} +

+
, + props.container + ) +} diff --git a/web/src/features/channels/components/channels-primary-buttons.tsx b/web/src/features/channels/components/channels-primary-buttons.tsx index 2999d33645..d8f1944177 100644 --- a/web/src/features/channels/components/channels-primary-buttons.tsx +++ b/web/src/features/channels/components/channels-primary-buttons.tsx @@ -17,6 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { useQueryClient } from '@tanstack/react-query' +import { getRouteApi } from '@tanstack/react-router' import { Plus, MoreHorizontal, @@ -26,7 +27,6 @@ import { TestTube, DollarSign, ListChecks, - SortAsc, RefreshCw, ArrowUpFromLine, } from 'lucide-react' @@ -45,6 +45,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Label } from '@/components/ui/label' +import { Separator } from '@/components/ui/separator' import { Switch } from '@/components/ui/switch' import { Tooltip, @@ -64,19 +65,25 @@ import { handleTestAllChannels, handleUpdateAllBalances, } from '../lib' +import type { ChannelSortBy } from '../types' +import { ChannelSortControl } from './channel-priority-controls' import { useChannels } from './channels-provider' +const route = getRouteApi('/_authenticated/channels/') + export function ChannelsPrimaryButtons() { const { t } = useTranslation() + const navigate = route.useNavigate() const { setOpen, setCurrentRow, enableTagMode, setEnableTagMode, - idSort, - setIdSort, + sorting, + setSorting, batchMode, setBatchMode, + setPriorityActionContainer, upstream, } = useChannels() const queryClient = useQueryClient() @@ -95,57 +102,60 @@ export function ChannelsPrimaryButtons() { setEnableTagMode(checked) } - const handleIdSortToggle = (checked: boolean) => { - localStorage.setItem('channels-id-sort', String(checked)) - setIdSort(checked) - } - const handleBatchModeToggle = (checked: boolean) => { setBatchMode(checked) } return ( <> -
+
+
{/* Desktop: Toggle switches visible */} -
- - - +
+
+ + +
+ +
+ + +
-
- - - -
- -
- - - -
+ { + setSorting([{ id: field, desc }]) + void navigate({ + search: (previous) => ({ ...previous, page: 1 }), + replace: true, + }) + }} + /> {/* Create Channel */} @@ -156,7 +166,6 @@ export function ChannelsPrimaryButtons() { setCurrentRow(null) setOpen('create-channel') }} - size='sm' disabled={!canEditSensitive} > @@ -173,8 +182,11 @@ export function ChannelsPrimaryButtons() { {/* More Actions */} - }> - + } + aria-label={t('More actions')} + > + {/* Mobile-only: toggle switches */} @@ -196,15 +208,6 @@ export function ChannelsPrimaryButtons() { {t('Tag Mode')} - - - {t('Sort by ID')} - - void enableTagMode: boolean setEnableTagMode: (enabled: boolean) => void - idSort: boolean - setIdSort: (enabled: boolean) => void + sorting: SortingState + setSorting: OnChangeFn batchMode: boolean setBatchMode: (enabled: boolean) => void sensitiveVisible: boolean setSensitiveVisible: (visible: boolean) => void + priorityActionContainer: HTMLDivElement | null + setPriorityActionContainer: (container: HTMLDivElement | null) => void upstream: UpstreamUpdateState } @@ -87,11 +91,22 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) { const [enableTagMode, setEnableTagMode] = useState(() => { return localStorage.getItem('enable-tag-mode') === 'true' }) - const [idSort, setIdSort] = useState(() => { - return localStorage.getItem('channels-id-sort') === 'true' - }) + const [sorting, setSorting] = useState(() => [ + { + id: + localStorage.getItem('channels-id-sort') === 'true' ? 'id' : 'priority', + desc: true, + }, + ]) + const idSort = sorting[0]?.id === 'id' + useEffect(() => { + // Retain the existing ID-sort preference for returning users. + localStorage.setItem('channels-id-sort', String(idSort)) + }, [idSort]) const [batchMode, setBatchMode] = useState(false) const [sensitiveVisible, setSensitiveVisible] = useState(true) + const [priorityActionContainer, setPriorityActionContainer] = + useState(null) const queryClient = useQueryClient() const refreshChannels = useCallback(async () => { @@ -112,12 +127,14 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) { setCurrentTag, enableTagMode, setEnableTagMode, - idSort, - setIdSort, + sorting, + setSorting, batchMode, setBatchMode, sensitiveVisible, setSensitiveVisible, + priorityActionContainer, + setPriorityActionContainer, upstream, }), [ @@ -125,9 +142,10 @@ export function ChannelsProvider({ children }: { children: React.ReactNode }) { currentRow, currentTag, enableTagMode, - idSort, + sorting, batchMode, sensitiveVisible, + priorityActionContainer, upstream, ] ) diff --git a/web/src/features/channels/components/channels-table.tsx b/web/src/features/channels/components/channels-table.tsx index 069cdc8b00..c1f9821c0d 100644 --- a/web/src/features/channels/components/channels-table.tsx +++ b/web/src/features/channels/components/channels-table.tsx @@ -23,9 +23,11 @@ import type { OnChangeFn, SortingState, Row, + ColumnDef, } from '@tanstack/react-table' import { Eye, EyeOff, RefreshCw } from 'lucide-react' import { useState, useMemo, useEffect } from 'react' +import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' import { @@ -34,6 +36,7 @@ import { DataTablePage, useDebouncedColumnFilter, useDataTable, + useDataTableViewMode, } from '@/components/data-table' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -44,8 +47,10 @@ import { } from '@/components/ui/tooltip' import { useMediaQuery } from '@/hooks' import { useTableUrlState } from '@/hooks/use-table-url-state' +import { hasPermission } from '@/lib/admin-permissions' import { requireServerSuccess } from '@/lib/server-error-message' import { cn } from '@/lib/utils' +import { useAuthStore } from '@/stores/auth-store' import { getChannels, searchChannels, getGroups } from '../api' import { @@ -53,6 +58,8 @@ import { CHANNEL_STATUS, CHANNEL_STATUS_OPTIONS, } from '../constants' +import { useChannelPriorityDrag } from '../hooks/use-channel-priority-drag' +import { useChannelPriorityOrder } from '../hooks/use-channel-priority-order' import { channelsQueryKeys, aggregateChannelsByTag, @@ -62,6 +69,13 @@ import { } from '../lib' import type { Channel, ChannelSortBy } from '../types' import { ChannelCard } from './channel-card' +import { ChannelPriorityContext } from './channel-priority-context' +import { + ChannelPriorityHandle, + ChannelPriorityModeToggle, + ChannelPriorityStatus, +} from './channel-priority-controls' +import { ChannelPriorityDropHint } from './channel-priority-drop-hint' import { ChannelTypeLogo } from './channel-type-badge' import { useChannelsColumns } from './channels-columns' import { useChannels } from './channels-provider' @@ -92,15 +106,16 @@ export function ChannelsTable() { const { t } = useTranslation() const { enableTagMode, - idSort, + sorting, + setSorting, batchMode, sensitiveVisible, setSensitiveVisible, + priorityActionContainer, } = useChannels() const isMobile = useMediaQuery('(max-width: 640px)') - // Table state - const [sorting, setSorting] = useState([]) + const idSort = sorting[0]?.id === 'id' // URL state management const { @@ -195,13 +210,10 @@ export function ChannelsTable() { }, [sorting]) const handleSortingChange: OnChangeFn = (updater) => { - setSorting((previous) => { - const next = typeof updater === 'function' ? updater(previous) : updater - if (pagination.pageIndex > 0) { - onPaginationChange({ ...pagination, pageIndex: 0 }) - } - return next - }) + setSorting(updater) + if (pagination.pageIndex > 0) { + onPaginationChange({ ...pagination, pageIndex: 0 }) + } } // Fetch groups for filter @@ -309,12 +321,89 @@ export function ChannelsTable() { const totalCount = data?.data?.total || 0 const typeCounts = data?.data?.type_counts - // Columns configuration - const columns = useChannelsColumns({ enableSelection: batchMode }) + const [viewMode, setViewMode] = useDataTableViewMode({ + storageKey: CHANNELS_VIEW_MODE_STORAGE_KEY, + defaultMode: 'card', + }) + const user = useAuthStore((state) => state.auth.user) + const sortField = (sorting[0]?.id ?? + (idSort ? 'id' : 'priority')) as ChannelSortBy + const sortDesc = sorting[0]?.desc ?? true + const [reorderEnabled, setReorderEnabled] = useState(false) + const canReorder = + sortField === 'priority' && + !enableTagMode && + hasPermission(user, 'channel', 'write') + const reorderActive = reorderEnabled && canReorder + useEffect(() => { + if (!canReorder) setReorderEnabled(false) + }, [canReorder]) + const reorderScope = JSON.stringify([ + viewMode, + reorderActive, + sorting, + idSort, + enableTagMode, + columnFilters, + globalFilter, + pagination, + channels.map((channel) => [channel.id, channel.priority]), + ]) + const priorityOrder = useChannelPriorityOrder({ + channels, + desc: sortDesc, + scope: reorderScope, + enabled: reorderActive, + }) + const drag = useChannelPriorityDrag({ + items: channels, + desc: sortDesc, + enabled: reorderActive && !priorityOrder.saving && !isFetching, + scope: reorderScope, + begin: priorityOrder.begin, + select: priorityOrder.select, + commit: priorityOrder.commit, + cancel: priorityOrder.cancel, + previewStore: priorityOrder.previewStore, + }) + let dragReason: 'sort' | 'tag' | 'permission' | 'busy' | null = null + if (sortField !== 'priority') dragReason = 'sort' + else if (enableTagMode) dragReason = 'tag' + else if (!hasPermission(user, 'channel', 'write')) dragReason = 'permission' + else if (priorityOrder.saving || isFetching) dragReason = 'busy' + + const dragControls = useMemo( + () => ({ reason: dragReason, start: drag.start, keyDown: drag.keyDown }), + [dragReason, drag.start, drag.keyDown] + ) + + // Reuse the same handle cell in the table and channel cards. + const baseColumns = useChannelsColumns({ enableSelection: batchMode }) + const columns = useMemo[]>( + () => + reorderActive + ? [ + { + id: 'reorder', + header: '', + size: 40, + minSize: 40, + maxSize: 40, + enableSorting: false, + enableHiding: false, + enableResizing: false, + meta: { mobileHidden: true }, + cell: ({ row }) => , + }, + ...baseColumns, + ] + : baseColumns, + [baseColumns, reorderActive] + ) // React Table instance const { table } = useDataTable({ - data: channels, + data: priorityOrder.items, columns, totalCount, sorting, @@ -412,110 +501,149 @@ export function ChannelsTable() { ] return ( - ( - - )} - cardGridClassName='grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-3' - applyHeaderSize - toolbarProps={{ - collapsibleOnMobile: true, - searchPlaceholder: t('Filter by name, ID, or key...'), - searchDebounceMs: 500, - onReset: () => { - resetModelFilterInput() - }, - additionalSearch: ( - - ), - filters: [ - { - columnId: 'status', - title: t('Status'), - options: [...CHANNEL_STATUS_OPTIONS], - singleSelect: true, - }, - { - columnId: 'type', - title: t('Type'), - options: typeFilterOptions, - singleSelect: true, - }, - { - columnId: 'group', - title: t('Group'), - options: groupFilterOptions, - singleSelect: true, - }, - ], - preActions: ( - <> - - setSensitiveVisible(!sensitiveVisible)} - aria-label={sensitiveVisible ? t('Hide') : t('Show')} - className='text-muted-foreground hover:text-foreground size-8' - /> - } - > - {sensitiveVisible ? : } - - - {sensitiveVisible ? t('Hide') : t('Show')} - - - - void refetch()} - aria-label={t('Refresh')} - aria-busy={isFetching} - className='text-muted-foreground hover:text-foreground size-8' - /> - } - > - - - {t('Refresh')} - - - ), - }} - getRowClassName={(row, { isMobile }) => { - if (!isDisabledChannelRow(row.original)) { - return undefined - } - if (isMobile) { - return DISABLED_ROW_MOBILE - } - return DISABLED_ROW_DESKTOP - }} - bulkActions={batchMode ? : null} - /> + + {priorityActionContainer && + createPortal( + setReorderEnabled((value) => !value)} + />, + priorityActionContainer + )} + +
+ ( + + )} + cardGridClassName='grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-3' + applyHeaderSize + afterTable={} + toolbarProps={{ + collapsibleOnMobile: true, + searchPlaceholder: t('Filter by name, ID, or key...'), + searchDebounceMs: 500, + onReset: () => { + resetModelFilterInput() + }, + additionalSearch: ( + + ), + filters: [ + { + columnId: 'status', + title: t('Status'), + options: [...CHANNEL_STATUS_OPTIONS], + singleSelect: true, + }, + { + columnId: 'type', + title: t('Type'), + options: typeFilterOptions, + singleSelect: true, + }, + { + columnId: 'group', + title: t('Group'), + options: groupFilterOptions, + singleSelect: true, + }, + ], + preActions: ( + <> + + setSensitiveVisible(!sensitiveVisible)} + aria-label={sensitiveVisible ? t('Hide') : t('Show')} + className='text-muted-foreground hover:text-foreground size-8' + /> + } + > + {sensitiveVisible ? ( + + ) : ( + + )} + + + {sensitiveVisible ? t('Hide') : t('Show')} + + + + void refetch()} + aria-label={t('Refresh')} + aria-busy={isFetching} + className='text-muted-foreground hover:text-foreground size-8' + /> + } + > + + + {t('Refresh')} + + + ), + }} + getRowClassName={(row, { isMobile }) => { + if (!isDisabledChannelRow(row.original)) { + return undefined + } + if (isMobile) { + return DISABLED_ROW_MOBILE + } + return DISABLED_ROW_DESKTOP + }} + bulkActions={ + batchMode ? : null + } + /> +
+
) } diff --git a/web/src/features/channels/components/numeric-spinner-input.tsx b/web/src/features/channels/components/numeric-spinner-input.tsx index 6890d3f3e3..ee5d325c94 100644 --- a/web/src/features/channels/components/numeric-spinner-input.tsx +++ b/web/src/features/channels/components/numeric-spinner-input.tsx @@ -167,6 +167,7 @@ export function NumericSpinnerInput({ {editing ? ( ) : (