Add persistent combo ordering and reorder sidebar items (#1095)

Integrated into release/v3.5.9 with openapi.yaml version fix
This commit is contained in:
Randi
2026-04-09 20:16:42 -04:00
committed by GitHub
parent 9f55159bd5
commit 74a5bcb3a9
16 changed files with 608 additions and 73 deletions

View File

@@ -486,6 +486,7 @@ Developers who want all responses in a specific language, with a specific tone,
- **9 Routing Strategies** — Global strategies that determine how requests are distributed
- **Wildcard Router** — `provider/*` patterns route dynamically to any provider
- **Combo Enable/Disable Toggle** — Toggle combos directly from the dashboard
- **Manual Combo Ordering** — Drag combo cards by handle and persist the order in SQLite
- **Provider Toggle** — Enable/disable all connections for a provider with one click
- **Blocked Providers** — Exclude specific providers from `/v1/models` listing

View File

@@ -73,7 +73,7 @@ Main pages under `src/app/(dashboard)/dashboard/`:
- `/dashboard` — quick start + provider overview
- `/dashboard/endpoint` — endpoint proxy + MCP + A2A + API endpoint tabs
- `/dashboard/providers` — provider connections and credentials
- `/dashboard/combos` — combo strategies, templates, model routing rules
- `/dashboard/combos` — combo strategies, templates, model routing rules, manual persisted ordering
- `/dashboard/costs` — cost aggregation and pricing visibility
- `/dashboard/analytics` — usage analytics and evaluations
- `/dashboard/limits` — quota/rate controls

View File

@@ -221,6 +221,8 @@ Models: kr/claude-sonnet-4.5, kr/claude-haiku-4.5
## 🎨 Combos
You can reorder combo cards directly in **Dashboard → Combos** by dragging the handle on each card. The order is stored in SQLite and restored on reload.
### Example 1: Maximize Subscription → Cheap Backup
```

View File

@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 3.5.8
version: 3.5.9
description: |
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
endpoint that routes requests to multiple AI providers with load balancing,

View File

@@ -331,6 +331,13 @@ function getI18nOrFallback(t, key, fallback) {
return fallback;
}
function moveArrayItem(items, fromIndex, toIndex) {
const nextItems = [...items];
const [movedItem] = nextItems.splice(fromIndex, 1);
nextItems.splice(toIndex, 0, movedItem);
return nextItems;
}
function getStrategyGuideText(t, strategy, field) {
const strategyFallback =
STRATEGY_GUIDANCE_FALLBACK[strategy] || STRATEGY_GUIDANCE_FALLBACK.priority;
@@ -388,6 +395,9 @@ export default function CombosPage() {
const [providerNodes, setProviderNodes] = useState([]);
const [showUsageGuide, setShowUsageGuide] = useState(true);
const [recentlyCreatedCombo, setRecentlyCreatedCombo] = useState("");
const [comboDragIndex, setComboDragIndex] = useState(null);
const [comboDragOverIndex, setComboDragOverIndex] = useState(null);
const [savingComboOrder, setSavingComboOrder] = useState(false);
useEffect(() => {
fetchData();
@@ -560,6 +570,75 @@ export default function CombosPage() {
} catch {}
};
const resetComboDragState = () => {
setComboDragIndex(null);
setComboDragOverIndex(null);
};
const handleComboDragStart = (e, index) => {
if (savingComboOrder || combos.length < 2) {
e.preventDefault();
return;
}
setComboDragIndex(index);
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", combos[index]?.id || `${index}`);
if (e.currentTarget instanceof HTMLElement) {
setTimeout(() => {
e.currentTarget.style.opacity = "0.5";
}, 0);
}
};
const handleComboDragEnd = (e) => {
if (e.currentTarget instanceof HTMLElement) {
e.currentTarget.style.opacity = "1";
}
resetComboDragState();
};
const handleComboDragOver = (e, index) => {
e.preventDefault();
if (comboDragIndex === null || comboDragIndex === index) return;
e.dataTransfer.dropEffect = "move";
setComboDragOverIndex(index);
};
const handleComboDrop = async (e, dropIndex) => {
e.preventDefault();
const fromIndex = comboDragIndex;
resetComboDragState();
if (fromIndex === null || fromIndex === dropIndex) return;
const previousCombos = combos;
const nextCombos = moveArrayItem(combos, fromIndex, dropIndex);
setCombos(nextCombos);
setSavingComboOrder(true);
try {
const res = await fetch("/api/combos/reorder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ comboIds: nextCombos.map((combo) => combo.id) }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error?.message || data.error || "Failed to reorder combos");
}
if (Array.isArray(data.combos)) {
setCombos(data.combos);
}
} catch {
setCombos(previousCombos);
notify.error(getI18nOrFallback(t, "failedReorder", "Failed to save combo order"));
} finally {
setSavingComboOrder(false);
}
};
if (loading) {
return (
<div className="flex flex-col gap-6">
@@ -655,23 +734,34 @@ export default function CombosPage() {
/>
) : (
<div className="flex flex-col gap-4">
{combos.map((combo) => (
<ComboCard
{combos.map((combo, index) => (
<div
key={combo.id}
combo={combo}
metrics={metrics[combo.name]}
providerNodes={providerNodes}
copied={copied}
onCopy={copy}
onEdit={() => setEditingCombo(combo)}
onDelete={() => handleDelete(combo.id)}
onDuplicate={() => handleDuplicate(combo)}
onTest={() => handleTestCombo(combo)}
testing={testingCombo === combo.name}
onProxy={() => setProxyTargetCombo(combo)}
hasProxy={!!proxyConfig?.combos?.[combo.id]}
onToggle={() => handleToggleCombo(combo)}
/>
data-testid={`combo-card-${combo.id}`}
onDragOver={(e) => handleComboDragOver(e, index)}
onDrop={(e) => handleComboDrop(e, index)}
>
<ComboCard
combo={combo}
metrics={metrics[combo.name]}
providerNodes={providerNodes}
copied={copied}
onCopy={copy}
onEdit={() => setEditingCombo(combo)}
onDelete={() => handleDelete(combo.id)}
onDuplicate={() => handleDuplicate(combo)}
onTest={() => handleTestCombo(combo)}
testing={testingCombo === combo.name}
onProxy={() => setProxyTargetCombo(combo)}
hasProxy={!!proxyConfig?.combos?.[combo.id]}
onToggle={() => handleToggleCombo(combo)}
dragDisabled={savingComboOrder || combos.length < 2}
isDragged={comboDragIndex === index}
isDropTarget={comboDragOverIndex === index && comboDragIndex !== index}
onDragStart={(e) => handleComboDragStart(e, index)}
onDragEnd={handleComboDragEnd}
/>
</div>
))}
</div>
)}
@@ -977,6 +1067,11 @@ function ComboCard({
hasProxy,
onToggle,
providerNodes,
dragDisabled,
isDragged,
isDropTarget,
onDragStart,
onDragEnd,
}) {
const strategy = combo.strategy || "priority";
const models = combo.models || [];
@@ -997,9 +1092,33 @@ function ComboCard({
};
return (
<Card padding="sm" className={`group ${isDisabled ? "opacity-50" : ""}`}>
<Card
padding="sm"
className={`group transition-all ${
isDisabled ? "opacity-50" : ""
} ${isDropTarget ? "border border-primary/30 bg-primary/5" : ""} ${
isDragged ? "opacity-60" : ""
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
<button
type="button"
draggable={!dragDisabled}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
data-testid={`combo-drag-handle-${combo.id}`}
className={`p-1 rounded-md transition-colors shrink-0 ${
dragDisabled
? "cursor-not-allowed text-text-muted/40"
: "cursor-grab active:cursor-grabbing text-text-muted hover:text-primary hover:bg-black/5 dark:hover:bg-white/5"
}`}
title={getI18nOrFallback(t, "reorderHandle", "Drag to reorder combo")}
aria-label={getI18nOrFallback(t, "reorderHandle", "Drag to reorder combo")}
>
<span className="material-symbols-outlined text-[18px]">drag_indicator</span>
</button>
{/* Icon */}
<div className="size-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<span className="material-symbols-outlined text-primary text-[18px]">layers</span>
@@ -1222,17 +1341,20 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
agentContextCache: boolean;
};
const getEmptyCreateDraftSnapshot = (): CreateDraftSnapshot => ({
name: "",
models: [],
strategy: "priority",
config: {},
showAdvanced: false,
nameError: "",
agentSystemMessage: "",
agentToolFilter: "",
agentContextCache: false,
});
const getEmptyCreateDraftSnapshot = useCallback(
(): CreateDraftSnapshot => ({
name: "",
models: [],
strategy: "priority",
config: {},
showAdvanced: false,
nameError: "",
agentSystemMessage: "",
agentToolFilter: "",
agentContextCache: false,
}),
[]
);
const t = useTranslations("combos");
const tc = useTranslations("common");
@@ -1486,7 +1608,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
return () => {
cancelled = true;
};
}, [combo, isOpen, resetFormForCombo]);
}, [combo, getEmptyCreateDraftSnapshot, isOpen, resetFormForCombo]);
useEffect(() => {
if (!strategyChangeMountedRef.current) {

View File

@@ -0,0 +1,51 @@
import { NextResponse } from "next/server";
import { reorderCombos, isCloudEnabled } from "@/lib/localDb";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { reorderCombosSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
// POST /api/combos/reorder - Persist combo ordering
export async function POST(request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = validateBody(reorderCombosSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const combos = await reorderCombos(validation.data.comboIds);
await syncToCloudIfEnabled();
return NextResponse.json({ combos });
} catch (error) {
console.log("Error reordering combos:", error);
return NextResponse.json({ error: "Failed to reorder combos" }, { status: 500 });
}
}
async function syncToCloudIfEnabled() {
try {
const cloudEnabled = await isCloudEnabled();
if (!cloudEnabled) return;
const machineId = await getConsistentMachineId();
await syncToCloud(machineId);
} catch (error) {
console.log("Error syncing to cloud:", error);
}
}

View File

@@ -17,33 +17,57 @@ function getSerializedData(value: unknown): string | null {
return typeof row.data === "string" ? row.data : null;
}
function getSortOrder(value: unknown): number | null {
const row = asRecord(value);
return typeof row.sort_order === "number" ? row.sort_order : null;
}
function withSortOrder(payload: string, sortOrder: number | null): JsonRecord {
const parsed = JSON.parse(payload) as JsonRecord;
if (typeof sortOrder === "number") {
parsed.sortOrder = sortOrder;
}
return parsed;
}
function getNextSortOrder() {
const db = getDbInstance();
const row = db.prepare("SELECT COALESCE(MAX(sort_order), 0) AS sort_order FROM combos").get();
const sortOrder = getSortOrder(row);
return (sortOrder ?? 0) + 1;
}
export async function getCombos() {
const db = getDbInstance();
return db
.prepare("SELECT data FROM combos ORDER BY name")
.prepare("SELECT data, sort_order FROM combos ORDER BY sort_order ASC, name COLLATE NOCASE ASC")
.all()
.map((row) => getSerializedData(row))
.filter((row): row is string => row !== null)
.map((row) => JSON.parse(row));
.map((row) => {
const payload = getSerializedData(row);
if (!payload) return null;
return withSortOrder(payload, getSortOrder(row));
})
.filter((row): row is JsonRecord => row !== null);
}
export async function getComboById(id: string) {
const db = getDbInstance();
const row = db.prepare("SELECT data FROM combos WHERE id = ?").get(id);
const row = db.prepare("SELECT data, sort_order FROM combos WHERE id = ?").get(id);
const payload = getSerializedData(row);
return payload ? JSON.parse(payload) : null;
return payload ? withSortOrder(payload, getSortOrder(row)) : null;
}
export async function getComboByName(name: string) {
const db = getDbInstance();
const row = db.prepare("SELECT data FROM combos WHERE name = ?").get(name);
const row = db.prepare("SELECT data, sort_order FROM combos WHERE name = ?").get(name);
const payload = getSerializedData(row);
return payload ? JSON.parse(payload) : null;
return payload ? withSortOrder(payload, getSortOrder(row)) : null;
}
export async function createCombo(data: JsonRecord) {
const db = getDbInstance();
const now = new Date().toISOString();
const sortOrder = typeof data.sortOrder === "number" ? data.sortOrder : getNextSortOrder();
const combo = {
id: uuidv4(),
@@ -52,13 +76,14 @@ export async function createCombo(data: JsonRecord) {
strategy: data.strategy || "priority",
config: data.config || {},
isHidden: Boolean(data.isHidden),
sortOrder,
createdAt: now,
updatedAt: now,
};
db.prepare(
"INSERT INTO combos (id, name, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?)"
).run(combo.id, combo.name, JSON.stringify(combo), now, now);
"INSERT INTO combos (id, name, data, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
).run(combo.id, combo.name, JSON.stringify(combo), sortOrder, now, now);
backupDbFile("pre-write");
return combo;
@@ -66,23 +91,99 @@ export async function createCombo(data: JsonRecord) {
export async function updateCombo(id: string, data: JsonRecord) {
const db = getDbInstance();
const existing = db.prepare("SELECT data FROM combos WHERE id = ?").get(id);
const existing = db.prepare("SELECT data, sort_order FROM combos WHERE id = ?").get(id);
if (!existing) return null;
const serializedCurrent = getSerializedData(existing);
if (!serializedCurrent) return null;
const current = JSON.parse(serializedCurrent);
const merged = { ...current, ...data, updatedAt: new Date().toISOString() };
const current = withSortOrder(serializedCurrent, getSortOrder(existing));
const sortOrder =
typeof data.sortOrder === "number"
? data.sortOrder
: typeof current.sortOrder === "number"
? current.sortOrder
: getNextSortOrder();
const merged: JsonRecord = {
...current,
...data,
sortOrder,
updatedAt: new Date().toISOString(),
};
const currentName = typeof current.name === "string" ? current.name : "";
const nextName =
typeof merged["name"] === "string" && merged["name"].trim().length > 0
? merged["name"]
: currentName;
db.prepare("UPDATE combos SET name = ?, data = ?, updated_at = ? WHERE id = ?").run(
merged.name,
JSON.stringify(merged),
merged.updatedAt,
id
);
db.prepare(
"UPDATE combos SET name = ?, data = ?, sort_order = ?, updated_at = ? WHERE id = ?"
).run(nextName, JSON.stringify({ ...merged, name: nextName }), sortOrder, merged.updatedAt, id);
backupDbFile("pre-write");
return merged;
return { ...merged, name: nextName };
}
export async function reorderCombos(comboIds: string[]) {
const db = getDbInstance();
const rows = db
.prepare(
"SELECT id, name, data, sort_order FROM combos ORDER BY sort_order ASC, name COLLATE NOCASE ASC"
)
.all();
if (rows.length === 0) return [];
const existingIds = new Set(
rows
.map((row) => {
const record = asRecord(row);
return typeof record.id === "string" ? record.id : null;
})
.filter((id): id is string => id !== null)
);
const seen = new Set<string>();
const requestedIds = comboIds.filter((id) => {
if (!existingIds.has(id) || seen.has(id)) return false;
seen.add(id);
return true;
});
const orderedIds = [
...requestedIds,
...rows
.map((row) => {
const record = asRecord(row);
return typeof record.id === "string" ? record.id : null;
})
.filter((id): id is string => id !== null && !seen.has(id)),
];
const update = db.prepare(
"UPDATE combos SET data = ?, sort_order = ?, updated_at = ? WHERE id = ?"
);
const now = new Date().toISOString();
const rowById = new Map(
rows.map((row) => {
const record = asRecord(row);
return [String(record.id), row];
})
);
const reorderTransaction = db.transaction(() => {
orderedIds.forEach((id, index) => {
const row = rowById.get(id);
const payload = row ? getSerializedData(row) : null;
if (!payload) return;
const combo = withSortOrder(payload, getSortOrder(row));
const sortOrder = index + 1;
const updatedCombo = { ...combo, sortOrder, updatedAt: now };
update.run(JSON.stringify(updatedCombo), sortOrder, now, id);
});
});
reorderTransaction();
backupDbFile("pre-write");
return getCombos();
}
export async function deleteCombo(id: string) {

View File

@@ -111,6 +111,7 @@ const SCHEMA_SQL = `
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
data TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -406,6 +407,11 @@ function ensureCallLogsColumns(db: SqliteDatabase) {
}
}
function hasColumn(db: SqliteDatabase, tableName: string, columnName: string): boolean {
const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name?: string }>;
return rows.some((row) => row.name === columnName);
}
export function getDbInstance(): SqliteDatabase {
const existing = getDb();
if (existing) return existing;
@@ -512,6 +518,12 @@ export function getDbInstance(): SqliteDatabase {
INSERT OR IGNORE INTO _omniroute_migrations (version, name)
VALUES ('001', 'initial_schema');
`);
if (hasColumn(db, "combos", "sort_order")) {
db.prepare("INSERT OR IGNORE INTO _omniroute_migrations (version, name) VALUES (?, ?)").run(
"020",
"combo_sort_order"
);
}
runMigrations(db);
// Auto-migrate from db.json if exists
@@ -702,16 +714,21 @@ function migrateFromJson(db: SqliteDatabase, jsonPath: string) {
// 4. Combos
const insertCombo = db.prepare(`
INSERT OR REPLACE INTO combos (id, name, data, created_at, updated_at)
VALUES (@id, @name, @data, @createdAt, @updatedAt)
INSERT OR REPLACE INTO combos (id, name, data, sort_order, created_at, updated_at)
VALUES (@id, @name, @data, @sortOrder, @createdAt, @updatedAt)
`);
for (const combo of data.combos || []) {
for (const [index, combo] of (data.combos || []).entries()) {
const normalizedCombo = {
...combo,
sortOrder: typeof combo.sortOrder === "number" ? combo.sortOrder : index + 1,
};
insertCombo.run({
id: combo.id,
name: combo.name,
data: JSON.stringify(combo),
createdAt: combo.createdAt || new Date().toISOString(),
updatedAt: combo.updatedAt || new Date().toISOString(),
id: normalizedCombo.id,
name: normalizedCombo.name,
data: JSON.stringify(normalizedCombo),
sortOrder: normalizedCombo.sortOrder,
createdAt: normalizedCombo.createdAt || new Date().toISOString(),
updatedAt: normalizedCombo.updatedAt || new Date().toISOString(),
});
}

View File

@@ -75,8 +75,8 @@ export function runJsonMigration(
);
const insertCombo = db.prepare(`
INSERT OR REPLACE INTO combos (id, name, data, created_at, updated_at)
VALUES (@id, @name, @data, @createdAt, @updatedAt)
INSERT OR REPLACE INTO combos (id, name, data, sort_order, created_at, updated_at)
VALUES (@id, @name, @data, @sortOrder, @createdAt, @updatedAt)
`);
const insertKey = db.prepare(`
@@ -171,13 +171,18 @@ export function runJsonMigration(
}
// 5. Combos
for (const combo of data.combos ?? []) {
for (const [index, combo] of (data.combos ?? []).entries()) {
const normalizedCombo = {
...combo,
sortOrder: typeof combo.sortOrder === "number" ? combo.sortOrder : index + 1,
};
insertCombo.run({
id: combo.id,
name: combo.name,
data: JSON.stringify(combo),
createdAt: combo.createdAt ?? new Date().toISOString(),
updatedAt: combo.updatedAt ?? new Date().toISOString(),
id: normalizedCombo.id,
name: normalizedCombo.name,
data: JSON.stringify(normalizedCombo),
sortOrder: normalizedCombo.sortOrder,
createdAt: normalizedCombo.createdAt ?? new Date().toISOString(),
updatedAt: normalizedCombo.updatedAt ?? new Date().toISOString(),
});
}

View File

@@ -0,0 +1,16 @@
ALTER TABLE combos ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
WITH ordered_combos AS (
SELECT
id,
ROW_NUMBER() OVER (
ORDER BY created_at ASC, updated_at ASC, name COLLATE NOCASE ASC
) AS next_sort_order
FROM combos
)
UPDATE combos
SET sort_order = (
SELECT next_sort_order
FROM ordered_combos
WHERE ordered_combos.id = combos.id
);

View File

@@ -75,6 +75,7 @@ export {
getComboByName,
createCombo,
updateCombo,
reorderCombos,
deleteCombo,
} from "./db/combos";

View File

@@ -7,8 +7,8 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
"auto-combo",
"costs",
"analytics",
"limits",
"cache",
"limits",
"cli-tools",
"agents",
"memory",
@@ -17,8 +17,8 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
"playground",
"media",
"search-tools",
"health",
"logs",
"health",
"audit",
"settings",
"docs",
@@ -55,8 +55,8 @@ const PRIMARY_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
{ id: "auto-combo", href: "/dashboard/auto-combo", i18nKey: "autoCombo", icon: "auto_awesome" },
{ id: "costs", href: "/dashboard/costs", i18nKey: "costs", icon: "account_balance_wallet" },
{ id: "analytics", href: "/dashboard/analytics", i18nKey: "analytics", icon: "analytics" },
{ id: "limits", href: "/dashboard/limits", i18nKey: "limits", icon: "tune" },
{ id: "cache", href: "/dashboard/cache", i18nKey: "cache", icon: "cached" },
{ id: "limits", href: "/dashboard/limits", i18nKey: "limits", icon: "tune" },
{ id: "media", href: "/dashboard/cache/media", i18nKey: "media", icon: "perm_media" },
];
@@ -79,8 +79,8 @@ const DEBUG_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
];
const SYSTEM_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
{ id: "health", href: "/dashboard/health", i18nKey: "health", icon: "health_and_safety" },
{ id: "logs", href: "/dashboard/logs", i18nKey: "logs", icon: "description" },
{ id: "health", href: "/dashboard/health", i18nKey: "health", icon: "health_and_safety" },
{ id: "audit", href: "/dashboard/audit", i18nKey: "auditLog", icon: "history" },
{ id: "settings", href: "/dashboard/settings", i18nKey: "settings", icon: "settings" },
];

View File

@@ -925,6 +925,20 @@ export const updateComboSchema = z
}
});
export const reorderCombosSchema = z
.object({
comboIds: z.array(z.string().trim().min(1).max(200)).min(1).max(1000),
})
.superRefine((value, ctx) => {
if (new Set(value.comboIds).size !== value.comboIds.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "comboIds must be unique",
path: ["comboIds"],
});
}
});
export const testComboSchema = z.object({
comboName: z.string().trim().min(1, "comboName is required"),
});

View File

@@ -7,6 +7,7 @@ type ComboStub = {
models: unknown[];
config: Record<string, unknown>;
isActive: boolean;
sortOrder?: number;
};
type ComboCreatePayload = {
@@ -210,4 +211,134 @@ test.describe("Combos flow", () => {
const testResultsModal = page.getByRole("dialog").last();
await expect(testResultsModal).toContainText(/qa-test-model/i);
});
test("allows dragging combo cards to persist manual order", async ({ page }) => {
const state: {
combos: ComboStub[];
reorderRequests: number;
} = {
combos: [
{
id: "combo-1",
name: "alpha-combo",
strategy: "priority",
models: ["openai/alpha"],
config: {},
isActive: true,
sortOrder: 1,
},
{
id: "combo-2",
name: "bravo-combo",
strategy: "priority",
models: ["openai/bravo"],
config: {},
isActive: true,
sortOrder: 2,
},
{
id: "combo-3",
name: "charlie-combo",
strategy: "priority",
models: ["openai/charlie"],
config: {},
isActive: true,
sortOrder: 3,
},
],
reorderRequests: 0,
};
await page.route("**/api/combos/metrics", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ metrics: {} }),
});
});
await page.route("**/api/settings/proxy", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ combos: {} }),
});
});
await page.route("**/api/providers", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ connections: [] }),
});
});
await page.route("**/api/provider-nodes", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ nodes: [] }),
});
});
await page.route("**/api/combos/reorder", async (route) => {
state.reorderRequests += 1;
const payload = route.request().postDataJSON() as { comboIds?: string[] };
const nextIds = Array.isArray(payload?.comboIds) ? payload.comboIds : [];
const comboById = new Map(state.combos.map((combo) => [combo.id, combo]));
state.combos = nextIds
.map((id, index) => {
const combo = comboById.get(id);
return combo ? { ...combo, sortOrder: index + 1 } : null;
})
.filter((combo): combo is ComboStub => combo !== null);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ combos: state.combos }),
});
});
await page.route("**/api/combos", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ combos: state.combos }),
});
});
await page.goto("/dashboard/combos");
await page.waitForLoadState("networkidle");
const redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
const comboCards = page.locator('[data-testid^="combo-card-"]');
await expect
.poll(async () =>
comboCards.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")))
)
.toEqual(["combo-card-combo-1", "combo-card-combo-2", "combo-card-combo-3"]);
await page
.getByTestId("combo-drag-handle-combo-3")
.dragTo(page.getByTestId("combo-card-combo-1"));
await expect.poll(() => state.reorderRequests).toBe(1);
await expect
.poll(async () =>
comboCards.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")))
)
.toEqual(["combo-card-combo-3", "combo-card-combo-1", "combo-card-combo-2"]);
await page.reload();
await page.waitForLoadState("networkidle");
await expect
.poll(async () =>
comboCards.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")))
)
.toEqual(["combo-card-combo-3", "combo-card-combo-1", "combo-card-combo-2"]);
});
});

View File

@@ -47,11 +47,12 @@ test("createCombo stores default strategy and supports lookup by id and name", a
});
assert.equal(combo.strategy, "priority");
assert.equal(combo.sortOrder, 1);
assert.deepEqual(await combosDb.getComboById(combo.id), combo);
assert.deepEqual(await combosDb.getComboByName("Priority Combo"), combo);
});
test("getCombos returns parsed combos sorted by name", async () => {
test("getCombos returns parsed combos in persisted sort order", async () => {
await combosDb.createCombo({
name: "Zulu",
models: [{ provider: "openai", model: "gpt-4.1" }],
@@ -65,7 +66,11 @@ test("getCombos returns parsed combos sorted by name", async () => {
assert.deepEqual(
combos.map((combo) => combo.name),
["Alpha", "Zulu"]
["Zulu", "Alpha"]
);
assert.deepEqual(
combos.map((combo) => combo.sortOrder),
[1, 2]
);
});
@@ -91,6 +96,35 @@ test("updateCombo merges fields while preserving immutable data", async () => {
assert.deepEqual(await combosDb.getComboById(combo.id), updated);
});
test("reorderCombos persists manual combo ordering in sqlite", async () => {
const alpha = await combosDb.createCombo({
name: "Alpha",
models: [{ provider: "openai", model: "gpt-4.1" }],
});
const bravo = await combosDb.createCombo({
name: "Bravo",
models: [{ provider: "anthropic", model: "claude-3-7-sonnet" }],
});
const charlie = await combosDb.createCombo({
name: "Charlie",
models: [{ provider: "google", model: "gemini-2.5-pro" }],
});
const reordered = await combosDb.reorderCombos([charlie.id, alpha.id, bravo.id]);
assert.deepEqual(
reordered.map((combo) => combo.name),
["Charlie", "Alpha", "Bravo"]
);
assert.deepEqual(
reordered.map((combo) => combo.sortOrder),
[1, 2, 3]
);
assert.equal((await combosDb.getComboById(charlie.id))?.sortOrder, 1);
assert.equal((await combosDb.getComboById(alpha.id))?.sortOrder, 2);
assert.equal((await combosDb.getComboById(bravo.id))?.sortOrder, 3);
});
test("deleteCombo reports missing ids and removes existing rows", async () => {
const combo = await combosDb.createCombo({
name: "Delete Me",

View File

@@ -0,0 +1,40 @@
import test from "node:test";
import assert from "node:assert/strict";
const sidebarVisibility = await import("../../src/shared/constants/sidebarVisibility.ts");
test("system sidebar items place logs before health", () => {
const systemSection = sidebarVisibility.SIDEBAR_SECTIONS.find(
(section) => section.id === "system"
);
assert.ok(systemSection, "expected system sidebar section to exist");
assert.deepEqual(
systemSection.items.map((item) => item.id),
["logs", "health", "audit", "settings"]
);
});
test("primary sidebar items place limits after cache", () => {
const primarySection = sidebarVisibility.SIDEBAR_SECTIONS.find(
(section) => section.id === "primary"
);
assert.ok(primarySection, "expected primary sidebar section to exist");
assert.deepEqual(
primarySection.items.map((item) => item.id),
[
"home",
"endpoints",
"api-manager",
"providers",
"combos",
"auto-combo",
"costs",
"analytics",
"cache",
"limits",
"media",
]
);
});