Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/dashboard-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@reddb-io/red-router": patch
---

Dashboard fixes.

- **Request details.** The request-details view no longer crashes when a response's content or thinking is an array of blocks.
- **Account order.** Reordering the accounts of a provider now saves the whole order at once, with priorities starting at 1. Before, it wrote two 0-based priorities in parallel, so whichever landed last won. With the owner filter active, it also moved the wrong rows. Accounts the user cannot see keep their place, and shared accounts stay admin-managed.
33 changes: 16 additions & 17 deletions src/app/(dashboard)/dashboard/providers/[id]/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -937,29 +937,28 @@ export default function ProviderDetailPage() {
}
};

// Indexes are positions in the list on screen (visibleConnections, which the
// owner filter narrows), not in `connections`. The new order is saved in one
// request; the server keeps hidden accounts where they were.
const handleSwapPriority = async (index1, index2) => {
// Optimistic update state
const newConnections = [...connections];
[newConnections[index1], newConnections[index2]] = [newConnections[index2], newConnections[index1]];
setConnections(newConnections);
const shown = [...visibleConnections];
if (!shown[index1] || !shown[index2]) return;
[shown[index1], shown[index2]] = [shown[index2], shown[index1]];
const shownIds = new Set(shown.map((c) => c.id));
const queue = [...shown];
setConnections((prev) => prev.map((c) => (shownIds.has(c.id) ? queue.shift() : c)));

try {
await Promise.all([
fetch(`/api/providers/${newConnections[index1].id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ priority: index1 }),
}),
fetch(`/api/providers/${newConnections[index2].id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ priority: index2 }),
}),
]);
const res = await fetch("/api/providers/reorder", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: providerId, orderedIds: shown.map((c) => c.id) }),
});
if (!res.ok) throw new Error(`reorder failed (${res.status})`);
} catch (error) {
console.log("Error swapping priority:", error);
await fetchConnections();
}
await fetchConnections();
};

const selectedConnections = connections.filter((conn) => selectedConnectionIds.includes(conn.id));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useState, useEffect, useCallback } from "react";
import { safeText } from "@/shared/utils/safeText";
import Card from "@/shared/components/Card";
import Button from "@/shared/components/Button";
import Drawer from "@/shared/components/Drawer";
Expand Down Expand Up @@ -778,7 +779,7 @@ export default function RequestDetailsTab() {
Thinking Process
</h4>
<pre className="max-h-[200px] max-w-full overflow-auto rounded-lg border border-amber-200 bg-amber-50 p-3 font-mono text-xs text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-100 sm:p-4">
{selectedDetail.response.thinking}
{safeText(selectedDetail.response.thinking)}
</pre>
</div>
)}
Expand All @@ -787,7 +788,7 @@ export default function RequestDetailsTab() {
Content
</h4>
<pre className="max-h-[300px] max-w-full overflow-auto rounded-lg border border-black/5 bg-black/5 p-3 font-mono text-xs text-text-main dark:border-white/5 dark:bg-white/5 sm:p-4">
{selectedDetail.response?.content || "[No content]"}
{safeText(selectedDetail.response?.content) || "[No content]"}
</pre>
</CollapsibleSection>
</div>
Expand Down
39 changes: 39 additions & 0 deletions src/app/api/providers/reorder/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { getProviderConnections, getSettings, setConnectionOrder } from "@/lib/localDb";
import { getRequestIdentity, getScopeFilter, isScopeEnabled, scopeVisible } from "@/lib/auth/resourceScope";

// PUT /api/providers/reorder body: { provider, orderedIds }
// The caller's visible accounts of one provider, in their new order. They take
// the positions those accounts held before, so accounts the caller cannot see
// keep theirs. One transaction, priorities 1..N.
export async function PUT(request) {
try {
const { provider, orderedIds } = await request.json();
if (typeof provider !== "string" || !provider || !Array.isArray(orderedIds) || orderedIds.some((id) => typeof id !== "string")) {
return NextResponse.json({ error: "provider and orderedIds[] required" }, { status: 400 });
}

const all = await getProviderConnections({ provider });
const visible = scopeVisible(all, await getScopeFilter());
const visibleIds = new Set(visible.map((c) => c.id));
if (orderedIds.length !== visibleIds.size || orderedIds.some((id) => !visibleIds.has(id)) || new Set(orderedIds).size !== orderedIds.length) {
return NextResponse.json({ error: "orderedIds must list each visible account of the provider once" }, { status: 400 });
}

// Shared accounts are ordered by the admin, as for a single-account edit.
const identity = await getRequestIdentity();
if (!identity.isAdmin && identity.owner && isScopeEnabled(await getSettings())
&& visible.some((c) => (c.owner ?? null) === null)) {
return NextResponse.json({ error: "Shared accounts are managed by the admin" }, { status: 403 });
}

// Fill the visible accounts' current slots with the new order.
const queue = [...orderedIds];
const fullOrder = all.map((c) => (visibleIds.has(c.id) ? queue.shift() : c.id));
await setConnectionOrder(provider, fullOrder);
return NextResponse.json({ success: true });
} catch (error) {
console.log("Error reordering connections:", error);
return NextResponse.json({ error: "Failed to reorder connections" }, { status: 500 });
}
}
2 changes: 1 addition & 1 deletion src/lib/db/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export {
getProviderConnections, getProviderConnectionById,
createProviderConnection, updateProviderConnection,
deleteProviderConnection, deleteProviderConnectionsByProvider,
reorderProviderConnections, cleanupProviderConnections,
reorderProviderConnections, setConnectionOrder, cleanupProviderConnections,
} from "./repos/connectionsRepo.js";

// Provider nodes
Expand Down
23 changes: 22 additions & 1 deletion src/lib/db/repos/connectionsRepo.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ async function reorderInTx(db, providerId) {
const list = (await db.selectFrom("providerConnections").selectAll()
.where("provider", "=", providerId).execute()).map(rowToConn);
list.sort((a, b) => {
const pDiff = (a.priority || 0) - (b.priority || 0);
// Same reading as getProviderConnections: a missing or 0 priority goes last.
const pDiff = (a.priority || 999) - (b.priority || 999);
if (pDiff !== 0) return pDiff;
return new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0);
});
Expand Down Expand Up @@ -309,6 +310,26 @@ export async function deleteProviderConnectionsByProvider(providerId) {
return deleted;
}

/**
* Set a provider's account order in one transaction: `orderedIds` first, in that
* order, then any account not listed, keeping its current place. Priorities are
* written 1..N. Replaces per-account priority writes that raced each other.
*/
export async function setConnectionOrder(providerId, orderedIds) {
const db = await getDb();
await db.transaction().execute(async (trx) => {
const list = (await trx.selectFrom("providerConnections").selectAll()
.where("provider", "=", providerId).execute()).map(rowToConn);
list.sort((a, b) => (a.priority || 999) - (b.priority || 999));
const byId = new Map(list.map((c) => [c.id, c]));
const first = [...new Set(orderedIds)].filter((id) => byId.has(id));
const rest = list.map((c) => c.id).filter((id) => !first.includes(id));
for (const [i, id] of [...first, ...rest].entries()) {
await trx.updateTable("providerConnections").set({ priority: i + 1 }).where("id", "=", id).execute();
}
});
}

export async function reorderProviderConnections(providerId) {
const db = await getDb();
await db.transaction().execute(async (trx) => reorderInTx(trx, providerId));
Expand Down
2 changes: 1 addition & 1 deletion src/lib/localDb.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export {
getProviderConnections, getProviderConnectionById,
createProviderConnection, updateProviderConnection,
deleteProviderConnection, deleteProviderConnectionsByProvider,
reorderProviderConnections, cleanupProviderConnections,
reorderProviderConnections, setConnectionOrder, cleanupProviderConnections,
getProviderNodes, getProviderNodeById,
createProviderNode, updateProviderNode, deleteProviderNode,
getProxyPools, getProxyPoolById,
Expand Down
14 changes: 14 additions & 0 deletions src/shared/utils/safeText.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Text for a <pre> from a stored value that may be a string, an array of content
* blocks or any object: React cannot render objects, and the request-details page
* crashed on responses whose content is an array (PentatonicDev cb937dd5).
*/
export function safeText(value) {
if (value == null) return null;
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
86 changes: 86 additions & 0 deletions tests/unit/dashboard-fixes.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Dashboard fixes: request details render any content shape; account order is
// saved in one transaction, keeps hidden accounts in place, and shared accounts
// stay admin-managed.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";

const scope = vi.hoisted(() => ({ identity: { isAdmin: true, owner: null }, visible: null, scoped: false }));
vi.mock("@/lib/auth/resourceScope", async (importOriginal) => {
const original = await importOriginal();
return {
...original,
getScopeFilter: async () => null,
getRequestIdentity: async () => scope.identity,
isScopeEnabled: () => scope.scoped,
scopeVisible: (list) => (scope.visible ? list.filter((c) => scope.visible.includes(c.id)) : list),
};
});

let db;
let PUT;
let safeText;

beforeAll(async () => {
db = await import("@/lib/db/index.js");
await db.initDb();
({ PUT } = await import("../../src/app/api/providers/reorder/route.js"));
({ safeText } = await import("../../src/shared/utils/safeText.js"));
});

beforeEach(() => {
scope.identity = { isAdmin: true, owner: null };
scope.visible = null;
scope.scoped = false;
});

async function makeProvider(provider, names) {
const ids = [];
for (const name of names) {
const conn = await db.createProviderConnection({ provider, authType: "apikey", name, apiKey: `k-${name}`, isActive: true, owner: null });
ids.push(conn.id);
}
return ids;
}
const order = async (provider) => (await db.getProviderConnections({ provider })).map((c) => [c.name, c.priority]);
const put = (body) => PUT(new Request("http://x/api/providers/reorder", { method: "PUT", body: JSON.stringify(body) }));

describe("safeText", () => {
it("renders strings as-is and anything else as JSON", () => {
expect(safeText("hi")).toBe("hi");
expect(safeText(null)).toBeNull();
expect(safeText([{ type: "text", text: "a" }])).toBe(JSON.stringify([{ type: "text", text: "a" }], null, 2));
});
});

describe("setConnectionOrder", () => {
it("writes the given order as priorities 1..N, unlisted accounts after, in place", async () => {
const [a, b, c] = await makeProvider("order-test", ["a", "b", "c"]);
await db.setConnectionOrder("order-test", [c, a]);
expect(await order("order-test")).toEqual([["c", 1], ["a", 2], ["b", 3]]);
await db.setConnectionOrder("order-test", [b, c, a, "unknown-id"]);
expect(await order("order-test")).toEqual([["b", 1], ["c", 2], ["a", 3]]);
});
});

describe("PUT /api/providers/reorder", () => {
it("reorders the visible accounts inside their own slots, leaving hidden ones where they were", async () => {
const [a, b, c, d] = await makeProvider("slots-test", ["a", "b", "c", "d"]);
scope.visible = [a, c, d]; // b is someone else's and stays at position 2
const res = await put({ provider: "slots-test", orderedIds: [d, a, c] });
expect(res.status).toBe(200);
expect(await order("slots-test")).toEqual([["d", 1], ["b", 2], ["a", 3], ["c", 4]]);
});

it("rejects an order that does not list every visible account exactly once", async () => {
const [a, b] = await makeProvider("bad-test", ["a", "b"]);
expect((await put({ provider: "bad-test", orderedIds: [a] })).status).toBe(400);
expect((await put({ provider: "bad-test", orderedIds: [a, a] })).status).toBe(400);
expect((await put({ provider: "bad-test", orderedIds: [a, b, "x"] })).status).toBe(400);
});

it("keeps shared accounts admin-managed when resources are scoped", async () => {
const [a, b] = await makeProvider("shared-test", ["a", "b"]);
scope.scoped = true;
scope.identity = { isAdmin: false, owner: "alice" };
expect((await put({ provider: "shared-test", orderedIds: [b, a] })).status).toBe(403);
});
});
Loading