mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
51
src/app/api/combos/reorder/route.ts
Normal file
51
src/app/api/combos/reorder/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
16
src/lib/db/migrations/020_combo_sort_order.sql
Normal file
16
src/lib/db/migrations/020_combo_sort_order.sql
Normal 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
|
||||
);
|
||||
@@ -75,6 +75,7 @@ export {
|
||||
getComboByName,
|
||||
createCombo,
|
||||
updateCombo,
|
||||
reorderCombos,
|
||||
deleteCombo,
|
||||
} from "./db/combos";
|
||||
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
40
tests/unit/sidebar-visibility.test.mjs
Normal file
40
tests/unit/sidebar-visibility.test.mjs
Normal 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",
|
||||
]
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user