mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
feat(dashboard): configurable sidebar — presets, DnD ordering, smart-grouping (#2581)
Integrated into release/v3.8.2
This commit is contained in:
56
package-lock.json
generated
56
package-lock.json
generated
@@ -13,6 +13,9 @@
|
||||
"open-sse"
|
||||
],
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@lobehub/icons": "^5.8.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
@@ -664,6 +667,59 @@
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
|
||||
@@ -134,6 +134,9 @@
|
||||
"build:cli-api": "node --import tsx/esm scripts/cli/generate-api-commands.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@lobehub/icons": "^5.8.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button, Card, Toggle } from "@/shared/components";
|
||||
import { useTheme } from "@/shared/hooks/useTheme";
|
||||
import useThemeStore, { COLOR_THEMES } from "@/store/themeStore";
|
||||
@@ -11,21 +12,11 @@ import {
|
||||
normalizeComboConfigMode,
|
||||
type ComboConfigMode,
|
||||
} from "@/shared/constants/comboConfigMode";
|
||||
import {
|
||||
HIDDEN_SIDEBAR_ITEMS_SETTING_KEY,
|
||||
SIDEBAR_SECTIONS,
|
||||
SIDEBAR_SETTINGS_UPDATED_EVENT,
|
||||
getSectionItems,
|
||||
normalizeHiddenSidebarItems,
|
||||
type HideableSidebarItemId,
|
||||
} from "@/shared/constants/sidebarVisibility";
|
||||
import { PIN_PROVIDER_QUOTA_TO_HOME_KEY } from "@/shared/constants/homeWidgets";
|
||||
|
||||
export default function AppearanceTab() {
|
||||
const { theme, setTheme, isDark } = useTheme();
|
||||
const { colorTheme, customColor, setColorTheme, setCustomColorTheme } = useThemeStore();
|
||||
const t = useTranslations("settings");
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
const [settings, setSettings] = useState<Record<string, any>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
@@ -33,22 +24,13 @@ export default function AppearanceTab() {
|
||||
const isValidHex = /^#([0-9a-fA-F]{6})$/.test(
|
||||
customThemeColor.startsWith("#") ? customThemeColor : `#${customThemeColor}`
|
||||
);
|
||||
const hiddenSidebarItems = normalizeHiddenSidebarItems(
|
||||
settings[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY]
|
||||
);
|
||||
const hiddenSidebarSet = new Set(hiddenSidebarItems);
|
||||
const comboConfigMode = normalizeComboConfigMode(settings[COMBO_CONFIG_MODE_SETTING_KEY]);
|
||||
const showCloudflaredTunnel = settings.hideEndpointCloudflaredTunnel !== true;
|
||||
const showTailscaleFunnel = settings.hideEndpointTailscaleFunnel !== true;
|
||||
const showNgrokTunnel = settings.hideEndpointNgrokTunnel !== true;
|
||||
const pinProviderQuotaToHome = settings[PIN_PROVIDER_QUOTA_TO_HOME_KEY] === true;
|
||||
const showQuickStartOnHome = settings.showQuickStartOnHome !== false; // default on
|
||||
const showProviderTopologyOnHome = settings.showProviderTopologyOnHome !== false; // default on
|
||||
|
||||
const getSettingsLabel = (key: string, fallback: string) =>
|
||||
typeof t.has === "function" && t.has(key) ? t(key) : fallback;
|
||||
const getSidebarLabel = (key: string, fallback: string) =>
|
||||
typeof tSidebar.has === "function" && tSidebar.has(key) ? tSidebar(key) : fallback;
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = useThemeStore.subscribe((state) => {
|
||||
@@ -74,12 +56,7 @@ export default function AppearanceTab() {
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
setSettings({
|
||||
...data,
|
||||
[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY]: normalizeHiddenSidebarItems(
|
||||
data[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY]
|
||||
),
|
||||
});
|
||||
setSettings(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
@@ -93,18 +70,7 @@ export default function AppearanceTab() {
|
||||
body: JSON.stringify({ [key]: value }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
[key]:
|
||||
key === HIDDEN_SIDEBAR_ITEMS_SETTING_KEY ? normalizeHiddenSidebarItems(value) : value,
|
||||
}));
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SIDEBAR_SETTINGS_UPDATED_EVENT, {
|
||||
detail: { [key]: value },
|
||||
})
|
||||
);
|
||||
}
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to update", key, err);
|
||||
@@ -147,23 +113,6 @@ export default function AppearanceTab() {
|
||||
},
|
||||
];
|
||||
|
||||
const showDebug = settings.debugMode === true;
|
||||
const sidebarSections = SIDEBAR_SECTIONS.filter(
|
||||
(section) => section.visibility !== "debug" || showDebug
|
||||
).map((section) => ({
|
||||
...section,
|
||||
title: getSidebarLabel(section.titleKey, section.titleFallback),
|
||||
items: getSectionItems(section).map((item) => ({ ...item, label: tSidebar(item.i18nKey) })),
|
||||
}));
|
||||
|
||||
const toggleSidebarItem = (itemId: HideableSidebarItemId) => {
|
||||
const nextHiddenItems = hiddenSidebarSet.has(itemId)
|
||||
? hiddenSidebarItems.filter((id) => id !== itemId)
|
||||
: [...hiddenSidebarItems, itemId];
|
||||
|
||||
updateSetting(HIDDEN_SIDEBAR_ITEMS_SETTING_KEY, nextHiddenItems);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
@@ -389,130 +338,27 @@ export default function AppearanceTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pin Information to Home Page section */}
|
||||
<div className="pt-4 border-t border-border">
|
||||
<div className="mb-3">
|
||||
<p className="font-medium">
|
||||
{getSettingsLabel("pinProviderQuotaToHome", "Pin Information to Home Page")}
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
Choose which sections to pin to the top of the Home page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Pinned options as a rounded table/list */}
|
||||
<div className="rounded-lg border border-border bg-surface/40 overflow-hidden">
|
||||
<div className="divide-y divide-border/70">
|
||||
{/* Provider Quota Limits */}
|
||||
<div className="flex items-start justify-between px-4 py-3">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{getSettingsLabel("providerQuotaLimits", "Provider Quota Limits")}
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
{getSettingsLabel(
|
||||
"providerQuotaLimitsDesc",
|
||||
"Pin the Provider Quota status container (with Refresh All button) to the top of the Home page."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={pinProviderQuotaToHome}
|
||||
onChange={() => updateSetting(PIN_PROVIDER_QUOTA_TO_HOME_KEY, !pinProviderQuotaToHome)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Start */}
|
||||
<div className="flex items-start justify-between px-4 py-3">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{getSettingsLabel("quickStart", "Quick Start")}
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
{getSettingsLabel(
|
||||
"quickStartDesc",
|
||||
"Show the Quick Start panel on the Home page."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={showQuickStartOnHome}
|
||||
onChange={() => updateSetting("showQuickStartOnHome", !showQuickStartOnHome)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Provider Topology */}
|
||||
<div className="flex items-start justify-between px-4 py-3">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{getSettingsLabel("providerTopology", "Provider Topology")}
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
{getSettingsLabel(
|
||||
"providerTopologyDesc",
|
||||
"Show the Provider Topology on the Home page."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={showProviderTopologyOnHome}
|
||||
onChange={() => updateSetting("showProviderTopologyOnHome", !showProviderTopologyOnHome)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-medium">{t("sidebarVisibilityToggle")}</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
{getSettingsLabel(
|
||||
"sidebarCustomizeLink",
|
||||
"Customize which items appear in the sidebar, their order, and apply role presets."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard/settings/sidebar"
|
||||
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border border-border hover:bg-surface/80 hover:border-primary/40 transition-colors text-text-main"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">view_sidebar</span>
|
||||
{getSettingsLabel("sidebarCustomizeLinkBtn", "Customize")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-border">
|
||||
<div className="mb-3">
|
||||
<p className="font-medium">{t("sidebarVisibilityToggle")}</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
{getSettingsLabel(
|
||||
"sidebarVisibilityDesc",
|
||||
"Hide any sidebar navigation entry to reduce visual clutter without disabling any features"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{sidebarSections.map((section) => (
|
||||
<div key={section.id} className="rounded-lg border border-border bg-surface/40">
|
||||
<div className="px-4 py-3 border-b border-border/70">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-text-muted/70">
|
||||
{section.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border/70">
|
||||
{section.items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between gap-4 px-4 py-3"
|
||||
>
|
||||
<p className="font-medium">{item.label}</p>
|
||||
<Toggle
|
||||
checked={!hiddenSidebarSet.has(item.id)}
|
||||
onChange={() => toggleSidebarItem(item.id)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs text-text-muted">
|
||||
{getSettingsLabel(
|
||||
"sidebarVisibilityHint",
|
||||
"Any sidebar section is hidden automatically when all of its entries are hidden"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
|
||||
613
src/app/(dashboard)/dashboard/settings/components/SidebarTab.tsx
Normal file
613
src/app/(dashboard)/dashboard/settings/components/SidebarTab.tsx
Normal file
@@ -0,0 +1,613 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Card, Toggle } from "@/shared/components";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
HIDDEN_SIDEBAR_ITEMS_SETTING_KEY,
|
||||
SIDEBAR_SECTION_ORDER_KEY,
|
||||
SIDEBAR_ITEM_ORDER_KEY,
|
||||
SIDEBAR_PRESET_KEY,
|
||||
SIDEBAR_SETTINGS_UPDATED_EVENT,
|
||||
SIDEBAR_SECTIONS,
|
||||
SIDEBAR_PRESETS,
|
||||
applySectionOrder,
|
||||
applyItemOrder,
|
||||
getSectionItems,
|
||||
normalizeHiddenSidebarItems,
|
||||
type HideableSidebarItemId,
|
||||
type SidebarSectionId,
|
||||
type SidebarItemOrder,
|
||||
type SidebarPresetId,
|
||||
type SidebarSectionDefinition,
|
||||
type SidebarSectionChild,
|
||||
type SidebarItemDefinition,
|
||||
type SidebarItemGroup,
|
||||
} from "@/shared/constants/sidebarVisibility";
|
||||
|
||||
// ─── Sortable section row ──────────────────────────────────────────────────────
|
||||
|
||||
interface SortableSectionProps {
|
||||
section: SidebarSectionDefinition & { title: string };
|
||||
hiddenSet: Set<HideableSidebarItemId>;
|
||||
itemOrder: string[];
|
||||
onToggleItem: (id: HideableSidebarItemId) => void;
|
||||
onItemReorder: (sectionId: SidebarSectionId, newOrder: string[]) => void;
|
||||
getLabel: (key: string, fallback: string) => string;
|
||||
}
|
||||
|
||||
function SortableSection({
|
||||
section,
|
||||
hiddenSet,
|
||||
itemOrder,
|
||||
onToggleItem,
|
||||
onItemReorder,
|
||||
getLabel,
|
||||
}: SortableSectionProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: section.id,
|
||||
});
|
||||
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
const allChildren = section.children as SidebarSectionChild[];
|
||||
const getChildId = (c: SidebarSectionChild) =>
|
||||
"type" in c && c.type === "group" ? c.id : (c as SidebarItemDefinition).id;
|
||||
|
||||
const orderedChildren = applyItemOrder(allChildren, itemOrder);
|
||||
const childIds = orderedChildren.map(getChildId);
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
const handleItemDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIdx = childIds.indexOf(active.id as string);
|
||||
const newIdx = childIds.indexOf(over.id as string);
|
||||
if (oldIdx === -1 || newIdx === -1) return;
|
||||
onItemReorder(section.id as SidebarSectionId, arrayMove(childIds, oldIdx, newIdx));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"rounded-lg border border-border bg-surface/40 transition-shadow",
|
||||
isDragging && "shadow-lg opacity-80"
|
||||
)}
|
||||
>
|
||||
{/* Section header */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-border/70">
|
||||
<button
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className="text-text-muted/40 hover:text-text-muted/80 cursor-grab active:cursor-grabbing touch-none shrink-0"
|
||||
title="Drag to reorder section"
|
||||
aria-label="Drag to reorder section"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">drag_indicator</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setExpanded((p) => !p)}
|
||||
className="flex-1 flex items-center gap-2 text-left"
|
||||
>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-text-muted/70">
|
||||
{section.title}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"material-symbols-outlined text-[14px] text-text-muted/40 transition-transform ml-auto",
|
||||
expanded && "rotate-180"
|
||||
)}
|
||||
>
|
||||
expand_more
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Section children with inner DnD */}
|
||||
{expanded && (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleItemDragEnd}
|
||||
>
|
||||
<SortableContext items={childIds} strategy={verticalListSortingStrategy}>
|
||||
<div className="divide-y divide-border/70">
|
||||
{orderedChildren.map((child) => {
|
||||
if ("type" in child && child.type === "group") {
|
||||
const group = child as SidebarItemGroup;
|
||||
return (
|
||||
<SortableChildRow key={group.id} id={group.id}>
|
||||
<GroupRow
|
||||
group={group}
|
||||
hiddenSet={hiddenSet}
|
||||
onToggleItem={onToggleItem}
|
||||
getLabel={getLabel}
|
||||
/>
|
||||
</SortableChildRow>
|
||||
);
|
||||
}
|
||||
const item = child as SidebarItemDefinition;
|
||||
return (
|
||||
<SortableChildRow key={item.id} id={item.id}>
|
||||
<ItemRow
|
||||
item={item}
|
||||
hiddenSet={hiddenSet}
|
||||
onToggleItem={onToggleItem}
|
||||
getLabel={getLabel}
|
||||
/>
|
||||
</SortableChildRow>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sortable child row wrapper ────────────────────────────────────────────────
|
||||
|
||||
function SortableChildRow({ id, children }: { id: string; children: React.ReactNode }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id,
|
||||
});
|
||||
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn("flex items-start gap-2", isDragging && "opacity-60")}
|
||||
>
|
||||
<button
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className="mt-3.5 ml-4 text-text-muted/30 hover:text-text-muted/70 cursor-grab active:cursor-grabbing touch-none shrink-0"
|
||||
title="Drag to reorder"
|
||||
aria-label="Drag to reorder"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">drag_indicator</span>
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Item row ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ItemRowProps {
|
||||
item: SidebarItemDefinition;
|
||||
hiddenSet: Set<HideableSidebarItemId>;
|
||||
onToggleItem: (id: HideableSidebarItemId) => void;
|
||||
getLabel: (key: string, fallback: string) => string;
|
||||
}
|
||||
|
||||
// Items that must always remain visible (safety guard)
|
||||
const PROTECTED_ITEM_IDS = new Set(["settings-sidebar"]);
|
||||
|
||||
function ItemRow({ item, hiddenSet, onToggleItem, getLabel }: ItemRowProps) {
|
||||
const isProtected = PROTECTED_ITEM_IDS.has(item.id);
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="material-symbols-outlined text-[16px] text-text-muted/50 shrink-0">
|
||||
{item.icon}
|
||||
</span>
|
||||
<p className="font-medium truncate">{getLabel(item.i18nKey, item.id)}</p>
|
||||
</div>
|
||||
{isProtected ? (
|
||||
<span
|
||||
className="material-symbols-outlined text-[16px] text-text-muted/40"
|
||||
title="This item cannot be hidden"
|
||||
aria-label="Always visible"
|
||||
>
|
||||
lock
|
||||
</span>
|
||||
) : (
|
||||
<Toggle checked={!hiddenSet.has(item.id)} onChange={() => onToggleItem(item.id)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Group row (items inside group, no sub-DnD) ───────────────────────────────
|
||||
|
||||
interface GroupRowProps {
|
||||
group: SidebarItemGroup;
|
||||
hiddenSet: Set<HideableSidebarItemId>;
|
||||
onToggleItem: (id: HideableSidebarItemId) => void;
|
||||
getLabel: (key: string, fallback: string) => string;
|
||||
}
|
||||
|
||||
function GroupRow({ group, hiddenSet, onToggleItem, getLabel }: GroupRowProps) {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<div className="w-full">
|
||||
<button
|
||||
onClick={() => setOpen((p) => !p)}
|
||||
className="flex items-center gap-2 px-4 py-2.5 w-full text-left hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"material-symbols-outlined text-[12px] text-text-muted/40 transition-transform",
|
||||
open && "rotate-90"
|
||||
)}
|
||||
>
|
||||
chevron_right
|
||||
</span>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-text-muted/50">
|
||||
{getLabel(group.titleKey, group.titleFallback)}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-text-muted/40">
|
||||
{group.items.filter((i) => !hiddenSet.has(i.id)).length}/{group.items.length}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="divide-y divide-border/50 pl-2">
|
||||
{group.items.map((item) => (
|
||||
<div key={item.id} className="flex items-center justify-between gap-4 px-4 py-2.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted/40 shrink-0">
|
||||
{item.icon}
|
||||
</span>
|
||||
<p className="text-sm font-medium truncate">{getLabel(item.i18nKey, item.id)}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={!hiddenSet.has(item.id)}
|
||||
onChange={() => onToggleItem(item.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function SidebarTab() {
|
||||
const t = useTranslations("settings");
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
|
||||
const getLabel = useCallback(
|
||||
(key: string, fallback: string) =>
|
||||
typeof tSidebar.has === "function" && tSidebar.has(key) ? tSidebar(key) : fallback,
|
||||
[tSidebar]
|
||||
);
|
||||
const getSettingsLabel = useCallback(
|
||||
(key: string, fallback: string) =>
|
||||
typeof t.has === "function" && t.has(key) ? t(key) : fallback,
|
||||
[t]
|
||||
);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hiddenSidebarItems, setHiddenSidebarItems] = useState<HideableSidebarItemId[]>([]);
|
||||
const [sectionOrder, setSectionOrder] = useState<SidebarSectionId[]>([]);
|
||||
const [itemOrder, setItemOrder] = useState<SidebarItemOrder>({});
|
||||
const [activePreset, setActivePreset] = useState<SidebarPresetId | null>(null);
|
||||
const [confirmPreset, setConfirmPreset] = useState<SidebarPresetId | null>(null);
|
||||
const [showDebug, setShowDebug] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setHiddenSidebarItems(
|
||||
normalizeHiddenSidebarItems(data?.[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY])
|
||||
);
|
||||
setSectionOrder(
|
||||
Array.isArray(data?.[SIDEBAR_SECTION_ORDER_KEY]) ? data[SIDEBAR_SECTION_ORDER_KEY] : []
|
||||
);
|
||||
setItemOrder(
|
||||
data?.[SIDEBAR_ITEM_ORDER_KEY] && typeof data[SIDEBAR_ITEM_ORDER_KEY] === "object"
|
||||
? data[SIDEBAR_ITEM_ORDER_KEY]
|
||||
: {}
|
||||
);
|
||||
setActivePreset(data?.[SIDEBAR_PRESET_KEY] ?? null);
|
||||
setShowDebug(data?.debugMode === true);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const patch = async (updates: Record<string, unknown>) => {
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
if (res.ok) {
|
||||
window.dispatchEvent(new CustomEvent(SIDEBAR_SETTINGS_UPDATED_EVENT, { detail: updates }));
|
||||
} else {
|
||||
console.error("Failed to update sidebar settings:", res.statusText);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error updating sidebar settings:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleItem = (id: HideableSidebarItemId) => {
|
||||
// Protected items can never be hidden
|
||||
if (PROTECTED_ITEM_IDS.has(id)) return;
|
||||
const next = hiddenSidebarItems.includes(id)
|
||||
? hiddenSidebarItems.filter((x) => x !== id)
|
||||
: [...hiddenSidebarItems, id];
|
||||
setHiddenSidebarItems(next);
|
||||
// Any manual change → custom mode
|
||||
setActivePreset(null);
|
||||
patch({ [HIDDEN_SIDEBAR_ITEMS_SETTING_KEY]: next, [SIDEBAR_PRESET_KEY]: null });
|
||||
};
|
||||
|
||||
const hiddenSet = new Set(hiddenSidebarItems);
|
||||
|
||||
const visibleSections = SIDEBAR_SECTIONS.filter((s) => s.visibility !== "debug" || showDebug).map(
|
||||
(s) => ({ ...s, title: getLabel(s.titleKey, s.titleFallback) })
|
||||
);
|
||||
|
||||
const orderedSections = applySectionOrder(visibleSections, sectionOrder);
|
||||
|
||||
const sectionIds = orderedSections.map((s) => s.id);
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
const handleSectionDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIdx = sectionIds.indexOf(active.id as SidebarSectionId);
|
||||
const newIdx = sectionIds.indexOf(over.id as SidebarSectionId);
|
||||
if (oldIdx === -1 || newIdx === -1) return;
|
||||
const newOrder = arrayMove(sectionIds, oldIdx, newIdx) as SidebarSectionId[];
|
||||
setSectionOrder(newOrder);
|
||||
setActivePreset(null);
|
||||
patch({ [SIDEBAR_SECTION_ORDER_KEY]: newOrder, [SIDEBAR_PRESET_KEY]: null });
|
||||
};
|
||||
|
||||
const handleItemReorder = (sectionId: SidebarSectionId, newOrder: string[]) => {
|
||||
const next = { ...itemOrder, [sectionId]: newOrder };
|
||||
setItemOrder(next);
|
||||
setActivePreset(null);
|
||||
patch({ [SIDEBAR_ITEM_ORDER_KEY]: next, [SIDEBAR_PRESET_KEY]: null });
|
||||
};
|
||||
|
||||
const applyPreset = (presetId: SidebarPresetId) => {
|
||||
const preset = SIDEBAR_PRESETS.find((p) => p.id === presetId);
|
||||
if (!preset) return;
|
||||
// Ensure protected items are never hidden, even if a preset includes them
|
||||
const safeHidden = preset.hiddenItems.filter((id) => !PROTECTED_ITEM_IDS.has(id));
|
||||
setHiddenSidebarItems(safeHidden);
|
||||
setSectionOrder([]);
|
||||
setItemOrder({});
|
||||
setActivePreset(presetId);
|
||||
setConfirmPreset(null);
|
||||
patch({
|
||||
[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY]: safeHidden,
|
||||
[SIDEBAR_SECTION_ORDER_KEY]: [],
|
||||
[SIDEBAR_ITEM_ORDER_KEY]: {},
|
||||
[SIDEBAR_PRESET_KEY]: presetId,
|
||||
});
|
||||
};
|
||||
|
||||
const resetToDefault = () => applyPreset("all");
|
||||
|
||||
const presetLabels: Record<SidebarPresetId, string> = {
|
||||
all: getSettingsLabel("presetAll", "All"),
|
||||
minimal: getSettingsLabel("presetMinimal", "Minimal"),
|
||||
developer: getSettingsLabel("presetDeveloper", "Developer"),
|
||||
admin: getSettingsLabel("presetAdmin", "Admin"),
|
||||
};
|
||||
|
||||
const presetDescriptions: Record<SidebarPresetId, string> = {
|
||||
all: getSettingsLabel("presetAllDesc", "Show everything"),
|
||||
minimal: getSettingsLabel("presetMinimalDesc", "Core pages only"),
|
||||
developer: getSettingsLabel("presetDeveloperDesc", "Dev & proxy tools"),
|
||||
admin: getSettingsLabel("presetAdminDesc", "Monitoring & audit"),
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
view_sidebar
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{getSettingsLabel("settingsSidebarTitle", "Sidebar Customization")}
|
||||
</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
{getSettingsLabel(
|
||||
"settingsSidebarDesc",
|
||||
"Control which items appear in the sidebar and their order"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Presets */}
|
||||
<div>
|
||||
<div className="mb-3">
|
||||
<p className="font-medium">{getSettingsLabel("sidebarPresets", "Presets")}</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
{getSettingsLabel(
|
||||
"sidebarPresetsDesc",
|
||||
"Start from a role-based layout. Any change after applying a preset switches to Custom."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Active preset badge */}
|
||||
<div className="mb-3 flex items-center gap-2 text-sm">
|
||||
<span className="text-text-muted">
|
||||
{getSettingsLabel("activePresetLabel", "Active:")}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded-full text-xs font-medium",
|
||||
activePreset
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-surface border border-border text-text-muted"
|
||||
)}
|
||||
>
|
||||
{activePreset
|
||||
? presetLabels[activePreset]
|
||||
: getSettingsLabel("presetCustom", "Custom")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
{SIDEBAR_PRESETS.map((preset) => {
|
||||
const isActive = activePreset === preset.id;
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
if (activePreset === preset.id) return;
|
||||
if (
|
||||
activePreset !== null ||
|
||||
hiddenSidebarItems.length > 0 ||
|
||||
sectionOrder.length > 0
|
||||
) {
|
||||
setConfirmPreset(preset.id);
|
||||
} else {
|
||||
applyPreset(preset.id);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1.5 p-3 rounded-lg border transition-colors disabled:opacity-60",
|
||||
isActive
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border hover:border-primary/40 bg-surface/40 text-text-main"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined text-[22px]"
|
||||
style={isActive ? { fontVariationSettings: "'FILL' 1" } : {}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{preset.icon}
|
||||
</span>
|
||||
<span className="text-sm font-semibold">{presetLabels[preset.id]}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] text-center",
|
||||
isActive ? "text-primary/70" : "text-text-muted"
|
||||
)}
|
||||
>
|
||||
{presetDescriptions[preset.id]}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Confirm preset dialog */}
|
||||
{confirmPreset && (
|
||||
<div className="mt-3 p-3 rounded-lg border border-amber-500/30 bg-amber-500/5 flex items-center gap-3">
|
||||
<span className="material-symbols-outlined text-amber-500 text-[18px] shrink-0">
|
||||
warning
|
||||
</span>
|
||||
<p className="text-sm flex-1">
|
||||
{getSettingsLabel(
|
||||
"presetConfirmWarning",
|
||||
`Applying "${presetLabels[confirmPreset]}" will replace your current visibility and order settings.`
|
||||
)}
|
||||
</p>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => setConfirmPreset(null)}
|
||||
className="px-3 py-1.5 text-sm rounded-md border border-border hover:bg-surface/80 transition-colors"
|
||||
>
|
||||
{getSettingsLabel("cancelLabel", "Cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => applyPreset(confirmPreset)}
|
||||
className="px-3 py-1.5 text-sm rounded-md bg-primary text-white hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{getSettingsLabel("applyLabel", "Apply")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Visibility & order */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{getSettingsLabel("sidebarOrder", "Visibility & Order")}
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
{getSettingsLabel(
|
||||
"sidebarOrderDesc",
|
||||
"Toggle items on/off and drag to reorder sections and their entries."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={resetToDefault}
|
||||
disabled={loading}
|
||||
className="shrink-0 text-sm text-text-muted hover:text-text-main border border-border rounded-md px-3 py-1.5 hover:bg-surface/80 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{getSettingsLabel("resetDefault", "Reset to default")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleSectionDragEnd}
|
||||
>
|
||||
<SortableContext items={sectionIds} strategy={verticalListSortingStrategy}>
|
||||
{orderedSections.map((section) => (
|
||||
<SortableSection
|
||||
key={section.id}
|
||||
section={section}
|
||||
hiddenSet={hiddenSet}
|
||||
itemOrder={itemOrder[section.id as SidebarSectionId] ?? []}
|
||||
onToggleItem={toggleItem}
|
||||
onItemReorder={handleItemReorder}
|
||||
getLabel={getLabel}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs text-text-muted">
|
||||
{getSettingsLabel(
|
||||
"sidebarVisibilityHint",
|
||||
"A sidebar section hides automatically when all of its entries are hidden"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
7
src/app/(dashboard)/dashboard/settings/sidebar/page.tsx
Normal file
7
src/app/(dashboard)/dashboard/settings/sidebar/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import SidebarTab from "../components/SidebarTab";
|
||||
|
||||
export default function SettingsSidebarPage() {
|
||||
return <SidebarTab />;
|
||||
}
|
||||
@@ -842,7 +842,6 @@
|
||||
"settingsAi": "AI Settings",
|
||||
"settingsSecurity": "Security",
|
||||
"settingsFeatureFlags": "Feature Flags",
|
||||
"settingsAuthz": "Authz",
|
||||
"settingsRouting": "Routing",
|
||||
"settingsResilience": "Resilience",
|
||||
"settingsAdvanced": "Advanced",
|
||||
@@ -914,16 +913,12 @@
|
||||
"settingsAdvancedSubtitle": "Power user options",
|
||||
"settingsSecuritySubtitle": "Auth and encryption",
|
||||
"settingsFeatureFlagsSubtitle": "Toggle system capabilities",
|
||||
"settingsAuthzSubtitle": "Route inventory and bypass policy",
|
||||
"docsSubtitle": "Documentation",
|
||||
"issuesSubtitle": "Report a bug",
|
||||
"changelogSubtitle": "Release notes",
|
||||
"leaderboard": "Leaderboard",
|
||||
"leaderboardSubtitle": "Top contributors and usage rankings",
|
||||
"profile": "Profile",
|
||||
"profileSubtitle": "Your account profile",
|
||||
"tokens": "Tokens",
|
||||
"tokensSubtitle": "Token balance and history"
|
||||
"settingsSidebar": "Sidebar",
|
||||
"settingsSidebarSubtitle": "Sidebar customization",
|
||||
"gamificationGroup": "Gamification"
|
||||
},
|
||||
"webhooks": {
|
||||
"title": "Webhooks",
|
||||
@@ -1296,7 +1291,6 @@
|
||||
"keyName": "Key Name",
|
||||
"keyNamePlaceholder": "e.g. Production Key",
|
||||
"keyNameDesc": "Choose a descriptive name to identify this key's purpose",
|
||||
"managementAccessDesc": "Allow this API key to manage OmniRoute configuration.",
|
||||
"keyCreated": "API Key Created",
|
||||
"keyCreatedSuccess": "Key created successfully!",
|
||||
"keyCreatedNote": "Copy and store this key now — it won't be shown again.",
|
||||
@@ -1634,7 +1628,6 @@
|
||||
"amp": "Use when you want Amp shorthand workflows but still need OmniRoute alias and routing rules enforcement.",
|
||||
"qwen": "Use when you need Alibaba Qwen Code CLI for coding tasks.",
|
||||
"hermes": "Use when you need a lightweight terminal-native AI assistant for quick tasks.",
|
||||
"hermes-agent": "Use when you need Hermes Agent (by Nousresearch) with default, delegation, vision and auxiliary models routed through OmniRoute.",
|
||||
"custom": "Use for custom tool implementations or generic OpenAI-compatible configurations."
|
||||
},
|
||||
"toolDescriptions": {
|
||||
@@ -1654,7 +1647,6 @@
|
||||
"qwen": "Alibaba Qwen Code CLI",
|
||||
"amp": "Sourcegraph Amp coding assistant CLI",
|
||||
"hermes": "Hermes AI Terminal Assistant",
|
||||
"hermes-agent": "Hermes Agent (by Nousresearch) - Advanced terminal AI with multi-model support (delegation, vision, compression, etc.)",
|
||||
"custom": "Generic OpenAI-compatible CLI or SDK configuration generator"
|
||||
},
|
||||
"guides": {
|
||||
@@ -2041,17 +2033,6 @@
|
||||
"recommendationsApplied": "Recommendations applied to this combo.",
|
||||
"intelligentPanelTitle": "Intelligent Routing Dashboard",
|
||||
"intelligentPanelDesc": "Real-time scoring and health status for this auto-routing combo.",
|
||||
"configOnlyStatus": "Configuration View",
|
||||
"configOnlyHint": "This panel shows routing inputs only. Live breaker state is available on the Health page.",
|
||||
"routingInputs": "Routing Inputs",
|
||||
"routingInputsHint": "Mode pack and weighting stay here; breaker runtime state stays on the Health page.",
|
||||
"emailVisibilityHint": "Account emails here follow the global privacy toggle.",
|
||||
"emailVisibilityTooltip": "Use the eye icon to reveal or hide account emails globally across combos, providers and quota screens.",
|
||||
"manualModel": "Manual model",
|
||||
"manualModelInvalid": "Enter a model as provider/model.",
|
||||
"manualModelUnknownProvider": "Unknown provider prefix.",
|
||||
"builderDynamicAccountShort": "Dynamic account",
|
||||
"builderNeedValidName": "Define a valid combo name before continuing.",
|
||||
"statusOverview": "Status Overview",
|
||||
"normalOperation": "Normal Operation",
|
||||
"allProvidersHealthy": "Providers are reporting healthy routing conditions.",
|
||||
@@ -2361,8 +2342,7 @@
|
||||
"previousPeriod": "Previous Half",
|
||||
"currentPeriod": "Current Half",
|
||||
"exportCSV": "Export as CSV",
|
||||
"exportJSON": "Export as JSON",
|
||||
"legacyFreeLabel": "Legacy / Free"
|
||||
"exportJSON": "Export as JSON"
|
||||
},
|
||||
"endpoint": {
|
||||
"title": "API Endpoint",
|
||||
@@ -3645,12 +3625,6 @@
|
||||
"geminiCliProjectIdLabel": "Google Cloud Project ID",
|
||||
"geminiCliProjectIdPlaceholder": "my-gcp-project-id",
|
||||
"antigravityProjectIdHint": "Optional override for Antigravity Cloud Code requests. Leave blank to use the project discovered during Google OAuth.",
|
||||
"antigravityClientProfileLabel": "Client profile",
|
||||
"antigravityClientProfileHint": "Choose which Antigravity client identity OmniRoute presents to the API.",
|
||||
"antigravityClientProfileIde": "IDE",
|
||||
"antigravityClientProfileHarness": "Harness / CLI",
|
||||
"codexFastTierActiveChip": "Codex Fast tier is active",
|
||||
"tierFast": "Fast",
|
||||
"antigravityProjectIdLabel": "Google Cloud Project ID",
|
||||
"antigravityProjectIdPlaceholder": "my-gcp-project-id",
|
||||
"grokWebCookieHint": "Grok Web Cookie Hint",
|
||||
@@ -3758,65 +3732,6 @@
|
||||
"cache": "Cache",
|
||||
"resilience": "Resilience",
|
||||
"routingSettingsIntro": "Controls how your requests are routed, transformed, and sent to AI providers.",
|
||||
"routingOpDropParagraphContainsLabel": "Drop paragraph (contains)",
|
||||
"routingOpDropParagraphStartsWithLabel": "Drop paragraph (starts with)",
|
||||
"routingOpReplaceTextLabel": "Replace text",
|
||||
"routingOpReplaceRegexLabel": "Replace regex",
|
||||
"routingOpDropBlockContainsLabel": "Drop block (contains)",
|
||||
"routingOpPrependSystemBlockLabel": "Prepend system block",
|
||||
"routingOpAppendSystemBlockLabel": "Append system block",
|
||||
"routingOpInjectBillingHeaderLabel": "Inject billing header",
|
||||
"routingOpObfuscateWordsLabel": "Obfuscate words (ZWJ)",
|
||||
"routingOpDropParagraphContainsDesc": "Removes any paragraph (text block split on blank lines) inside the system prompt whose text contains ANY of the listed substrings. Use to strip third-party client fingerprints like 'github.com/anomalyco/opencode' or 'docs.openwebui.com' that Anthropic's classifier flags.",
|
||||
"routingOpDropParagraphStartsWithDesc": "Removes any paragraph that STARTS WITH one of the listed prefixes. Use for identity lines like 'You are OpenCode' or 'You are Open WebUI' that announce the calling client.",
|
||||
"routingOpReplaceTextDesc": "Replaces a literal substring with another literal substring. Use for known trigger phrases — e.g. rewrite 'Here is some useful information about the environment you are running in:' to 'Environment context you are running in:' (an empirically-validated trigger phrase).",
|
||||
"routingOpReplaceRegexDesc": "Replaces text matching a regular expression. Use when you need patterns (character classes, optional whitespace, anchors) instead of literal substrings. A malformed pattern is caught at runtime when the op runs.",
|
||||
"routingOpDropBlockContainsDesc": "Removes ENTIRE system blocks (not just paragraphs) whose text contains any of the listed substrings. Use when a whole block is fingerprint-bearing and you want it gone — e.g. an injected MCP-server description.",
|
||||
"routingOpPrependSystemBlockDesc": "Inserts a new text block at the FRONT of the system array. Use to add the SDK identity 'You are a Claude agent, built on Anthropic's Claude Agent SDK.' that Anthropic's classifier expects.",
|
||||
"routingOpAppendSystemBlockDesc": "Inserts a new text block at the END of the system array. Use for cosmetic additions that don't need to be at position [0].",
|
||||
"routingOpInjectBillingHeaderDesc": "Prepends the special 'x-anthropic-billing-header: cc_version=...; cc_entrypoint=...; cch=...;' text block that Anthropic's classifier validates. Required for CC bridge relay endpoints; for the native claude provider OmniRoute already injects its own billing line so this op is usually redundant there.",
|
||||
"routingOpObfuscateWordsDesc": "Inserts a Zero-Width-Joiner character after the first letter of each listed word, so 'opencode' becomes 'o\u200dpencode'. Reads identical to humans but bypasses classifier word matches. Targets system blocks, user/assistant messages, and tool descriptions.",
|
||||
"routingNeedlesHint": "List of substrings. A paragraph matches if it contains ANY one of them. Add one per line via 'Add entry'.",
|
||||
"routingPrefixesHint": "List of strings. A paragraph matches if it starts with any one of them (leading whitespace is trimmed before matching).",
|
||||
"routingCaseSensitiveHint": "When ON, 'OpenCode' and 'opencode' are different strings. When OFF (default), the comparison ignores case.",
|
||||
"routingMatchLiteralHint": "Exact literal substring to find. No regex syntax — special chars like . * ? are treated as themselves.",
|
||||
"routingReplacementTextHint": "Replacement string. Leave blank to delete the match. The output preserves surrounding text.",
|
||||
"routingAllOccurrencesHint": "When ON (default), every instance is replaced. When OFF, only the first match is replaced.",
|
||||
"routingPatternHint": "JavaScript regex source. Don't wrap in slashes — just 'foo(.*)bar'. Server rejects patterns that fail to compile.",
|
||||
"routingRegexFlagsHint": "JavaScript regex flags (g = all matches, i = case-insensitive, s = dot matches newline, m = multiline). Default 'g'.",
|
||||
"routingBlockTextHint": "Full text of the new system block. Use a literal string; the system block stores text only.",
|
||||
"routingIdempotencyKeyHint": "Optional. If set, the op skips when a block whose text starts with this key is already present. Prevents double-prepend on retries.",
|
||||
"routingBillingEntrypointHint": "Value injected as 'cc_entrypoint='. Anthropic accepts 'sdk-cli' (Agent SDK), 'cli' (Claude Code CLI), or other documented values.",
|
||||
"routingBillingVersionFormatHint": "How the 3-char build hash after cc_version= is computed. 'ex-machina' = sha256 of CCH_SALT+chars-from-first-user-msg+version (per-message). 'omniroute-daystamp' = sha256 of YYYY-MM-DD+version (stable per-day).",
|
||||
"routingBillingCchAlgoHint": "How the 5-char cch= token is computed. 'sha256-first-user' = sha256 of first user message text. 'xxhash64-body' = body-level signing fills it later. 'static-zero' = literal '00000' placeholder.",
|
||||
"routingObfuscateWordsHint": "Lowercase words to obfuscate. ZWJ insertion is applied case-insensitively, so 'opencode' also matches 'OpenCode' and 'OPENCODE'.",
|
||||
"routingObfuscateTargetsHint": "Which body regions to scan for the words: system blocks, user/assistant messages, and/or tool descriptions.",
|
||||
"routingObfuscateTargetsLabel": "Targets",
|
||||
"routingSummarizeDropParagraphContains": "drop paragraphs containing: {items}",
|
||||
"routingSummarizeDropParagraphStartsWith": "drop paragraphs starting with: {items}",
|
||||
"routingSummarizeReplaceText": "replace \"{match}\" → \"{replacement}\"",
|
||||
"routingSummarizeReplaceRegex": "regex /{pattern}/{flags} → \"{replacement}\"",
|
||||
"routingSummarizeDropBlockContains": "drop blocks containing: {items}",
|
||||
"routingSummarizePrependSystemBlock": "prepend block: \"{text}\"",
|
||||
"routingSummarizeAppendSystemBlock": "append block: \"{text}\"",
|
||||
"routingSummarizeInjectBillingHeader": "inject billing header (entrypoint={entrypoint}, version={versionFormat}, cch={cchAlgo})",
|
||||
"routingSummarizeObfuscateWords": "obfuscate {count} word(s) via ZWJ in {targets}",
|
||||
"routingDefaultAutoVariantLKGP": "Last Known Good Provider",
|
||||
"routingDefaultAutoVariantLKGPDesc": "Last Known Good Provider",
|
||||
"routingDefaultAutoVariantCoding": "Quality-first for code",
|
||||
"routingDefaultAutoVariantCodingDesc": "Quality-first for code",
|
||||
"routingDefaultAutoVariantFast": "Low-latency routing",
|
||||
"routingDefaultAutoVariantFastDesc": "Low-latency routing",
|
||||
"routingDefaultAutoVariantCheap": "Cost-optimized",
|
||||
"routingDefaultAutoVariantCheapDesc": "Cost-optimized",
|
||||
"routingDefaultAutoVariantOffline": "High availability",
|
||||
"routingDefaultAutoVariantOfflineDesc": "High availability",
|
||||
"routingDefaultAutoVariantSmart": "Best discovery (10% explore)",
|
||||
"routingDefaultAutoVariantSmartDesc": "Best discovery (10% explore)",
|
||||
"routingOpSummaryCount": "{count, plural, =0 {no ops} one {# op} other {# ops}}",
|
||||
"routingOpEnabled": "enabled",
|
||||
"routingOpDisabled": "disabled",
|
||||
"routingOpStatusSeparator": " · ",
|
||||
"resilienceSettingsIntro": "Automatic retry, cooldown, and fallback when providers fail.",
|
||||
"aiSettingsIntro": "AI-specific settings for thinking budget, model behavior, and compression.",
|
||||
"systemPrompt": "System Prompt",
|
||||
@@ -3892,13 +3807,6 @@
|
||||
"systemTheme": "System Theme",
|
||||
"debugToggle": "Enable Debug Mode",
|
||||
"sidebarVisibilityToggle": "Show Sidebar Items",
|
||||
"pinProviderQuotaToHome": "Pin Information to Home Page",
|
||||
"providerQuotaLimits": "Provider Quota Limits",
|
||||
"providerQuotaLimitsDesc": "Pin the Provider Quota status container (with Refresh All button) to the top of the Home page.",
|
||||
"quickStart": "Quick Start",
|
||||
"quickStartDesc": "Show the Quick Start panel on the Home page.",
|
||||
"providerTopology": "Provider Topology",
|
||||
"providerTopologyDesc": "Show the Provider Topology on the Home page.",
|
||||
"enableCache": "Enable Cache",
|
||||
"cacheTTL": "Cache TTL",
|
||||
"maxCacheSize": "Max Cache Size",
|
||||
@@ -4578,27 +4486,13 @@
|
||||
"oneproxySuccess": "Success",
|
||||
"oneproxyFailed": "Failed",
|
||||
"routingAntigravitySignatureTitle": "Antigravity Signature Cache Mode",
|
||||
"routingAntigravitySignatureDesc": "Control whether OmniRoute reuses only stored Gemini thought signatures or accepts validated client-provided signatures in Antigravity-compatible tool-call flows.",
|
||||
"routingAntigravitySignatureEnabledLabel": "Enabled",
|
||||
"routingAntigravitySignatureEnabledDesc": "Current behavior. Ignore client-provided signatures and keep using the stored OmniRoute flow.",
|
||||
"routingAntigravitySignatureBypassLabel": "Bypass",
|
||||
"routingAntigravitySignatureBypassDesc": "Accept client-provided signatures after lightweight validation and fall back to the stored signature when invalid.",
|
||||
"routingAntigravitySignatureBypassStrictLabel": "Bypass Strict",
|
||||
"routingAntigravitySignatureBypassStrictDesc": "Require full protobuf validation before accepting a client-provided signature.",
|
||||
"routingHeaderFingerprintTitle": "Header fingerprint (per provider)",
|
||||
"routingServerRejectedSave": "⚠ Server rejected save:",
|
||||
"routingAddTransformOp": "Add a transform op",
|
||||
"routingClientCacheControlTitle": "Client Cache Control",
|
||||
"routingClientCacheControlDesc": "Configure whether OmniRoute preserves client-provided cache_control markers",
|
||||
"routingClientCacheControlAutoDesc": "For deterministic Claude-compatible flows, preserve client-provided cache_control as-is. If the request has no cache_control, OmniRoute does not inject any bridge-owned markers for CC-compatible third-party proxy compatibility.",
|
||||
"routingClientCacheControlAlwaysLabel": "Always Preserve",
|
||||
"routingClientCacheControlAlwaysDesc": "Always forward client-provided cache_control headers to upstream providers as-is.",
|
||||
"routingClientCacheControlNeverLabel": "Never Preserve",
|
||||
"routingClientCacheControlNeverDesc": "Always remove client cache_control headers and let OmniRoute manage caching where native provider flows support it.",
|
||||
"routingZeroConfigTitle": "Zero-Config Auto-Routing",
|
||||
"routingZeroConfigDesc": "Enable automatic provider selection using the auto/ prefix. When enabled, requests to auto, auto/coding, auto/fast, etc. will dynamically route across all connected providers.",
|
||||
"routingDefaultAutoVariant": "Default Auto Variant",
|
||||
"visionBridge": "Vision Bridge",
|
||||
"routingZeroConfigTitle": "Zero-Config Auto-Routing",
|
||||
"routingDefaultAutoVariant": "Default Auto Variant",
|
||||
"visionBridgeModel": "Bridge Model",
|
||||
"resilienceMaxBackoffSteps": "Max backoff steps",
|
||||
"resilienceBaseCooldownLabel": "Base cooldown",
|
||||
@@ -4666,74 +4560,36 @@
|
||||
"codexFastTierDesc": "Globally inject service_tier=priority for OpenAI Codex requests.",
|
||||
"codexFastTierHint": "When enabled, OmniRoute adds service_tier=priority to outbound Codex requests for connections that don't already specify a tier. Priority tier requires an OpenAI Enterprise API key or the ChatGPT-auth Codex path; other key types will receive a tier-related error from OpenAI. Per-connection settings on the Codex provider page take precedence.",
|
||||
"codexFastTierSaveError": "Failed to update Codex Fast Tier setting",
|
||||
"codexFastTierTierLabel": "Service tier",
|
||||
"codexFastTierTierPriority": "Priority",
|
||||
"codexFastTierTierFlex": "Flex",
|
||||
"codexFastTierTierDefault": "Default",
|
||||
"codexFastTierModelsLabel": "Fast-tier models",
|
||||
"codexFastTierModelsHint": "Only checked models receive service_tier when Fast Tier is enabled.",
|
||||
"codexFastTierModelCheckbox": "Enable Fast Tier for {model}",
|
||||
"claudeFastModeTitle": "Claude Fast Mode",
|
||||
"claudeFastModeDesc": "Opt selected Claude requests into Anthropic Fast Mode (speed:\"fast\").",
|
||||
"claudeFastModeHint": "Anthropic does not officially support Fast Mode for SDK-style clients. When enabled, OmniRoute forwards an X-CPA-Force-Fast-Mode header so a paired CLIProxyAPI build can opt-in spoof the entrypoint. Only the listed Opus models are gated by Anthropic's client-side check. Subscription tier, Max plan, and Fast Mode credit balance are still enforced server-side — Anthropic may return out_of_credits even when the toggle is on.",
|
||||
"claudeFastModeModelsLabel": "Applied to models ({count})",
|
||||
"claudeFastModeModelCheckbox": "Enable Fast Mode for {model}",
|
||||
"claudeFastModeSaveError": "Failed to update Claude Fast Mode setting",
|
||||
"authz": {
|
||||
"title": "Authz Inventory",
|
||||
"description": "5-tier route classification with live bypass policy. Read shows the full taxonomy; mutations re-prompt for the management password.",
|
||||
"loading": "Loading inventory…",
|
||||
"loadError": "Failed to load authz inventory",
|
||||
"tier": {
|
||||
"LOCAL_ONLY": "Local only",
|
||||
"ALWAYS_PROTECTED": "Always protected",
|
||||
"MANAGEMENT": "Management",
|
||||
"CLIENT_API": "Client API",
|
||||
"PUBLIC": "Public"
|
||||
},
|
||||
"bypass": {
|
||||
"section": "Manage-scope bypass",
|
||||
"kill_switch": {
|
||||
"label": "Bypass kill-switch",
|
||||
"desc": "Master toggle. When off, no LOCAL_ONLY prefix is reachable from non-loopback regardless of the per-prefix list."
|
||||
},
|
||||
"prefix": {
|
||||
"label": "Bypassable prefixes",
|
||||
"desc": "LOCAL_ONLY prefixes that manage-scope API keys (or dashboard sessions) may reach from non-loopback hosts.",
|
||||
"add": "Add prefix",
|
||||
"placeholder": "/api/mcp/v2/",
|
||||
"empty": "No prefixes configured. Bypass effectively off."
|
||||
},
|
||||
"cli_tools_runtime_note": "Spawn-capable prefix. Compile-time deny; cannot be made bypassable. Shown read-only."
|
||||
},
|
||||
"password": {
|
||||
"prompt": {
|
||||
"label": "Current password",
|
||||
"desc": "Re-confirm to apply security-impacting changes."
|
||||
},
|
||||
"placeholder": "Current management password",
|
||||
"cancel": "Cancel",
|
||||
"submit": "Apply"
|
||||
},
|
||||
"save": "Save changes",
|
||||
"saved": "Authz settings updated",
|
||||
"pending": "Unsaved changes",
|
||||
"badge": {
|
||||
"bypassable": "Bypassable via manage scope",
|
||||
"strict": "Strict loopback",
|
||||
"auth_required": "Auth required",
|
||||
"public": "Public",
|
||||
"always_protected": "Always protected",
|
||||
"spawn_capable": "Spawn-capable"
|
||||
},
|
||||
"error": {
|
||||
"PASSWORD_REQUIRED": "Current password required to apply these changes.",
|
||||
"PASSWORD_MISMATCH": "Current password is incorrect.",
|
||||
"INSUFFICIENT_SCOPE": "API key lacks the manage scope.",
|
||||
"BYPASS_PREFIX_NOT_ALLOWED": "One or more prefixes target spawn-capable routes and cannot be bypassed.",
|
||||
"GENERIC": "Failed to update authz settings."
|
||||
}
|
||||
}
|
||||
"settingsSidebarTitle": "Sidebar Customization",
|
||||
"settingsSidebarDesc": "Control which items appear in the sidebar and their order",
|
||||
"sidebarPresets": "Presets",
|
||||
"sidebarPresetsDesc": "Start from a role-based layout. Any change after applying a preset switches to Custom.",
|
||||
"presetAll": "All",
|
||||
"presetAllDesc": "Show everything",
|
||||
"presetMinimal": "Minimal",
|
||||
"presetMinimalDesc": "Core pages only",
|
||||
"presetDeveloper": "Developer",
|
||||
"presetDeveloperDesc": "Dev & proxy tools",
|
||||
"presetAdmin": "Admin",
|
||||
"presetAdminDesc": "Monitoring & audit",
|
||||
"presetCustom": "Custom",
|
||||
"activePresetLabel": "Active:",
|
||||
"presetConfirmWarning": "Applying this preset will replace your current visibility and order settings.",
|
||||
"cancelLabel": "Cancel",
|
||||
"applyLabel": "Apply",
|
||||
"sidebarOrder": "Visibility & Order",
|
||||
"sidebarOrderDesc": "Toggle items on/off and drag to reorder sections and their entries.",
|
||||
"sidebarCustomizeLink": "Customize which items appear in the sidebar, their order, and apply role presets.",
|
||||
"sidebarCustomizeLinkBtn": "Customize",
|
||||
"resetDefault": "Reset to default",
|
||||
"settingsSidebar": "Sidebar",
|
||||
"settingsSidebarSubtitle": "Visibility and order"
|
||||
},
|
||||
"contextRtk": {
|
||||
"title": "RTK Engine",
|
||||
@@ -4828,8 +4684,6 @@
|
||||
"enabled": "Enabled",
|
||||
"autoDetect": "Auto-detect language",
|
||||
"rulesCount": "{count} rules",
|
||||
"inputCompressionTitle": "Input compression",
|
||||
"inputCompressionDesc": "Rewrite chat history with shorter wording. Reduces input tokens by ~50%.",
|
||||
"analyticsTitle": "Compression Analytics",
|
||||
"noAnalytics": "No compression analytics yet.",
|
||||
"outputMode": "Output Mode",
|
||||
@@ -5231,25 +5085,7 @@
|
||||
"tierPro": "Pro",
|
||||
"tierPlus": "Plus",
|
||||
"tierFree": "Free",
|
||||
"tierLite": "Lite",
|
||||
"tierUnknown": "Unknown",
|
||||
"statTotal": "Total",
|
||||
"statCritical": "Critical",
|
||||
"statAlert": "Alert",
|
||||
"statHealthy": "Healthy",
|
||||
"filterPurchaseTypeLabel": "Type",
|
||||
"filterTierLabel": "Tier",
|
||||
"purchaseAll": "All",
|
||||
"purchaseOauthSub": "Subscription",
|
||||
"purchaseOauthFree": "OAuth Free",
|
||||
"purchaseApiKey": "API Key",
|
||||
"creditsLabel": "Credits",
|
||||
"creditBalanceHint": "Remaining balance",
|
||||
"unlimitedLabel": "Unlimited",
|
||||
"refreshing": "Refreshing",
|
||||
"resetsIn": "Resets in",
|
||||
"editCutoffs": "Edit cutoffs",
|
||||
"forceRefresh": "Refresh now",
|
||||
"suiteBuilderSaveFailed": "Failed to save suite",
|
||||
"clone": "Clone",
|
||||
"exportSuite": "Export",
|
||||
@@ -5395,9 +5231,7 @@
|
||||
"budgetWarnAtPct": "Warn at %",
|
||||
"quotaAlerts": "Quota alerts",
|
||||
"quotaTableRefreshing": "⟳ Refreshing...",
|
||||
"noSpendLast30Days": "No spend in last 30 days",
|
||||
"updatedShort": "Updated",
|
||||
"lastRefreshed": "Last refreshed"
|
||||
"noSpendLast30Days": "No spend in last 30 days"
|
||||
},
|
||||
"modals": {
|
||||
"waitingAuth": "Waiting for Authorization",
|
||||
@@ -6072,7 +5906,6 @@
|
||||
"clearError": "Failed to clear cache.",
|
||||
"unavailable": "Cache unavailable",
|
||||
"unavailableDesc": "Could not fetch cache statistics. Make sure the server is running.",
|
||||
"loadingCacheAria": "Loading cache",
|
||||
"promptCache": "Prompt Cache (Provider-Side)",
|
||||
"semanticCache": "Semantic Cache",
|
||||
"promptCacheSectionDesc": "Shows provider-side prompt caching activity from usage history so you can see where cache control is active and how much input reuse you are getting.",
|
||||
@@ -6412,8 +6245,6 @@
|
||||
"combo": "Combo",
|
||||
"keys": "Keys",
|
||||
"shown": "Shown",
|
||||
"loadMore": "Load more",
|
||||
"loadingMore": "Loading more...",
|
||||
"sortNewest": "Newest",
|
||||
"sortOldest": "Oldest",
|
||||
"sortTokensDesc": "Tokens ↓",
|
||||
@@ -6633,8 +6464,8 @@
|
||||
"equalSplit": "Equal split",
|
||||
"save": "Save allocations",
|
||||
"betaPreviewLabel": "Beta — UI preview.",
|
||||
"betaConfigSavedPrefix": "Configuration is saved in",
|
||||
"betaConfigSavedSuffix": "(not yet persisted on the server). Per-request cap enforcement is not yet wired into the proxy pipeline. This screen lets you design and visualize the quota split;",
|
||||
"betaConfigSavedPrefix": "A configuração é salva em",
|
||||
"betaConfigSavedSuffix": "(não persiste no servidor ainda). A aplicação dos caps por request ainda não está conectada ao pipeline da proxy. Esta tela permite desenhar e visualizar a divisão de cota;",
|
||||
"policyLabel": "Policy:"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -93,6 +93,9 @@ export async function getSettings() {
|
||||
mcpEnabled: false,
|
||||
a2aEnabled: false,
|
||||
hiddenSidebarItems: [],
|
||||
sidebarSectionOrder: [],
|
||||
sidebarItemOrder: {},
|
||||
sidebarActivePreset: null,
|
||||
hideEndpointCloudflaredTunnel: false,
|
||||
hideEndpointTailscaleFunnel: false,
|
||||
hideEndpointNgrokTunnel: false,
|
||||
|
||||
@@ -14,12 +14,17 @@ import { useTranslations } from "next-intl";
|
||||
import {
|
||||
HIDDEN_SIDEBAR_ITEMS_SETTING_KEY,
|
||||
SIDEBAR_SETTINGS_UPDATED_EVENT,
|
||||
SIDEBAR_SECTION_ORDER_KEY,
|
||||
SIDEBAR_ITEM_ORDER_KEY,
|
||||
SIDEBAR_SECTIONS,
|
||||
getSectionItems,
|
||||
normalizeHiddenSidebarItems,
|
||||
applySectionOrder,
|
||||
applyItemOrder,
|
||||
type SidebarSectionId,
|
||||
type SidebarItemDefinition,
|
||||
type SidebarItemGroup,
|
||||
type SidebarItemOrder,
|
||||
} from "@/shared/constants/sidebarVisibility";
|
||||
|
||||
const isE2EMode = process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE === "1";
|
||||
@@ -70,6 +75,8 @@ export default function Sidebar({
|
||||
const [isDisconnected, setIsDisconnected] = useState(false);
|
||||
const [showDebug, setShowDebug] = useState(false);
|
||||
const [hiddenSidebarItems, setHiddenSidebarItems] = useState<string[]>([]);
|
||||
const [sidebarSectionOrder, setSidebarSectionOrder] = useState<SidebarSectionId[]>([]);
|
||||
const [sidebarItemOrder, setSidebarItemOrder] = useState<SidebarItemOrder>({});
|
||||
const [customAppName, setCustomAppName] = useState<string | null>(null);
|
||||
const [customLogo, setCustomLogo] = useState<string | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<Set<SidebarSectionId>>(
|
||||
@@ -116,7 +123,15 @@ export default function Sidebar({
|
||||
|
||||
fetch("/api/settings")
|
||||
.then((res) => res.json())
|
||||
.then((data) => applySettings(data))
|
||||
.then((data) => {
|
||||
applySettings(data);
|
||||
if (Array.isArray(data?.[SIDEBAR_SECTION_ORDER_KEY])) {
|
||||
setSidebarSectionOrder(data[SIDEBAR_SECTION_ORDER_KEY] as SidebarSectionId[]);
|
||||
}
|
||||
if (data?.[SIDEBAR_ITEM_ORDER_KEY] && typeof data[SIDEBAR_ITEM_ORDER_KEY] === "object") {
|
||||
setSidebarItemOrder(data[SIDEBAR_ITEM_ORDER_KEY] as SidebarItemOrder);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
const handleSettingsUpdated = (event: Event) => {
|
||||
@@ -127,6 +142,16 @@ export default function Sidebar({
|
||||
normalizeHiddenSidebarItems(detail[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY])
|
||||
);
|
||||
}
|
||||
if (SIDEBAR_SECTION_ORDER_KEY in detail && Array.isArray(detail[SIDEBAR_SECTION_ORDER_KEY])) {
|
||||
setSidebarSectionOrder(detail[SIDEBAR_SECTION_ORDER_KEY] as SidebarSectionId[]);
|
||||
}
|
||||
if (
|
||||
SIDEBAR_ITEM_ORDER_KEY in detail &&
|
||||
detail[SIDEBAR_ITEM_ORDER_KEY] &&
|
||||
typeof detail[SIDEBAR_ITEM_ORDER_KEY] === "object"
|
||||
) {
|
||||
setSidebarItemOrder(detail[SIDEBAR_ITEM_ORDER_KEY] as SidebarItemOrder);
|
||||
}
|
||||
if ("instanceName" in detail) setCustomAppName((detail.instanceName as string) || null);
|
||||
if ("customLogoBase64" in detail) {
|
||||
setCustomLogo((detail.customLogoBase64 as string) || null);
|
||||
@@ -158,17 +183,27 @@ export default function Sidebar({
|
||||
|
||||
const hiddenSidebarSet = new Set(hiddenSidebarItems);
|
||||
|
||||
const visibleSections = SIDEBAR_SECTIONS.filter(
|
||||
(section) => section.visibility !== "debug" || showDebug
|
||||
)
|
||||
const orderedSections = applySectionOrder(
|
||||
SIDEBAR_SECTIONS.filter((section) => section.visibility !== "debug" || showDebug),
|
||||
sidebarSectionOrder
|
||||
);
|
||||
|
||||
const visibleSections = orderedSections
|
||||
.map((section) => {
|
||||
const children = section.children
|
||||
const orderedChildren = applyItemOrder(
|
||||
section.children,
|
||||
sidebarItemOrder[section.id as SidebarSectionId] ?? []
|
||||
);
|
||||
|
||||
const children = orderedChildren
|
||||
.map((child) => {
|
||||
if ("type" in child && child.type === "group") {
|
||||
const items = child.items
|
||||
.map((item) => resolveItem(item, hiddenSidebarSet))
|
||||
.filter(Boolean) as (SidebarItemDefinition & { label: string })[];
|
||||
if (items.length === 0) return null;
|
||||
// Smart-grouping: single visible item → inline flat (no group header)
|
||||
if (items.length === 1) return items[0];
|
||||
return {
|
||||
...child,
|
||||
title: getSidebarLabel(child.titleKey, child.titleFallback),
|
||||
|
||||
@@ -75,7 +75,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
|
||||
"settings-advanced",
|
||||
"settings-security",
|
||||
"settings-feature-flags",
|
||||
"settings-authz",
|
||||
"settings-sidebar",
|
||||
// Help
|
||||
"docs",
|
||||
"issues",
|
||||
@@ -661,11 +661,11 @@ const CONFIGURATION_ITEMS: readonly SidebarItemDefinition[] = [
|
||||
icon: "flag",
|
||||
},
|
||||
{
|
||||
id: "settings-authz",
|
||||
href: "/dashboard/settings/authz",
|
||||
i18nKey: "settingsAuthz",
|
||||
subtitleKey: "settingsAuthzSubtitle",
|
||||
icon: "shield_lock",
|
||||
id: "settings-sidebar",
|
||||
href: "/dashboard/settings/sidebar",
|
||||
i18nKey: "settingsSidebar",
|
||||
subtitleKey: "settingsSidebarSubtitle",
|
||||
icon: "view_sidebar",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -763,11 +763,159 @@ export const SIDEBAR_SECTIONS: readonly SidebarSectionDefinition[] = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
// ─── Settings helpers ─────────────────────────────────────────────────────────
|
||||
// ─── Ordering & preset setting keys ──────────────────────────────────────────
|
||||
|
||||
export const HIDDEN_SIDEBAR_ITEMS_SETTING_KEY = "hiddenSidebarItems";
|
||||
export const SIDEBAR_SECTION_ORDER_KEY = "sidebarSectionOrder";
|
||||
export const SIDEBAR_ITEM_ORDER_KEY = "sidebarItemOrder";
|
||||
export const SIDEBAR_PRESET_KEY = "sidebarActivePreset";
|
||||
export const SIDEBAR_SETTINGS_UPDATED_EVENT = "omniroute:settings-updated";
|
||||
|
||||
// ─── Preset types & definitions ───────────────────────────────────────────────
|
||||
|
||||
export type SidebarPresetId = "all" | "minimal" | "developer" | "admin";
|
||||
|
||||
export interface SidebarPresetDefinition {
|
||||
id: SidebarPresetId;
|
||||
icon: string;
|
||||
hiddenItems: HideableSidebarItemId[];
|
||||
}
|
||||
|
||||
const MINIMAL_SHOWN: ReadonlySet<HideableSidebarItemId> = new Set([
|
||||
"home",
|
||||
"endpoints",
|
||||
"api-manager",
|
||||
"providers",
|
||||
"combos",
|
||||
"analytics",
|
||||
"costs",
|
||||
"logs",
|
||||
"health",
|
||||
"settings",
|
||||
"settings-sidebar",
|
||||
"docs",
|
||||
"changelog",
|
||||
]);
|
||||
|
||||
const DEVELOPER_SHOWN: ReadonlySet<HideableSidebarItemId> = new Set([
|
||||
"home",
|
||||
"endpoints",
|
||||
"api-manager",
|
||||
"providers",
|
||||
"combos",
|
||||
"quota",
|
||||
"context-caveman",
|
||||
"context-rtk",
|
||||
"context-combos",
|
||||
"cli-tools",
|
||||
"agents",
|
||||
"api-endpoints",
|
||||
"analytics",
|
||||
"analytics-combo-health",
|
||||
"costs",
|
||||
"cache",
|
||||
"logs",
|
||||
"health",
|
||||
"runtime",
|
||||
"translator",
|
||||
"playground",
|
||||
"memory",
|
||||
"skills",
|
||||
"mcp",
|
||||
"a2a",
|
||||
"settings",
|
||||
"settings-routing",
|
||||
"settings-resilience",
|
||||
"settings-sidebar",
|
||||
"docs",
|
||||
"issues",
|
||||
"changelog",
|
||||
]);
|
||||
|
||||
const ADMIN_SHOWN: ReadonlySet<HideableSidebarItemId> = new Set([
|
||||
"home",
|
||||
"endpoints",
|
||||
"api-manager",
|
||||
"providers",
|
||||
"combos",
|
||||
"quota",
|
||||
"analytics",
|
||||
"analytics-combo-health",
|
||||
"analytics-utilization",
|
||||
"costs",
|
||||
"costs-pricing",
|
||||
"costs-budget",
|
||||
"costs-quota-share",
|
||||
"cache",
|
||||
"logs",
|
||||
"logs-activity",
|
||||
"health",
|
||||
"runtime",
|
||||
"audit",
|
||||
"audit-mcp",
|
||||
"audit-a2a",
|
||||
"settings",
|
||||
"settings-general",
|
||||
"settings-routing",
|
||||
"settings-resilience",
|
||||
"settings-security",
|
||||
"settings-feature-flags",
|
||||
"settings-sidebar",
|
||||
"docs",
|
||||
"changelog",
|
||||
]);
|
||||
|
||||
function buildHiddenList(shown: ReadonlySet<HideableSidebarItemId>): HideableSidebarItemId[] {
|
||||
return HIDEABLE_SIDEBAR_ITEM_IDS.filter((id) => !shown.has(id));
|
||||
}
|
||||
|
||||
export const SIDEBAR_PRESETS: readonly SidebarPresetDefinition[] = [
|
||||
{ id: "all", icon: "select_all", hiddenItems: [] },
|
||||
{ id: "minimal", icon: "minimize", hiddenItems: buildHiddenList(MINIMAL_SHOWN) },
|
||||
{ id: "developer", icon: "code", hiddenItems: buildHiddenList(DEVELOPER_SHOWN) },
|
||||
{ id: "admin", icon: "admin_panel_settings", hiddenItems: buildHiddenList(ADMIN_SHOWN) },
|
||||
];
|
||||
|
||||
export type SidebarItemOrder = Partial<Record<SidebarSectionId, string[]>>;
|
||||
|
||||
// ─── Ordering utilities ───────────────────────────────────────────────────────
|
||||
|
||||
export function applySectionOrder(
|
||||
sections: readonly SidebarSectionDefinition[],
|
||||
order: SidebarSectionId[]
|
||||
): SidebarSectionDefinition[] {
|
||||
if (order.length === 0) return [...sections];
|
||||
const knownIds = new Set(sections.map((s) => s.id));
|
||||
const validOrder = order.filter((id) => knownIds.has(id));
|
||||
const orderMap = new Map(validOrder.map((id, i) => [id, i]));
|
||||
return [...sections].sort((a, b) => {
|
||||
const ai = orderMap.get(a.id) ?? validOrder.length + sections.indexOf(a);
|
||||
const bi = orderMap.get(b.id) ?? validOrder.length + sections.indexOf(b);
|
||||
return ai - bi;
|
||||
});
|
||||
}
|
||||
|
||||
export function applyItemOrder(
|
||||
children: readonly SidebarSectionChild[],
|
||||
order: string[]
|
||||
): SidebarSectionChild[] {
|
||||
if (order.length === 0) return [...children];
|
||||
const getChildId = (c: SidebarSectionChild): string =>
|
||||
"type" in c && c.type === "group" ? c.id : (c as SidebarItemDefinition).id;
|
||||
const knownIds = new Set(children.map(getChildId));
|
||||
const validOrder = order.filter((id) => knownIds.has(id));
|
||||
const orderMap = new Map(validOrder.map((id, i) => [id, i]));
|
||||
return [...children].sort((a, b) => {
|
||||
const aId = getChildId(a);
|
||||
const bId = getChildId(b);
|
||||
const ai = orderMap.get(aId) ?? validOrder.length + children.indexOf(a);
|
||||
const bi = orderMap.get(bId) ?? validOrder.length + children.indexOf(b);
|
||||
return ai - bi;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Settings helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
export function normalizeHiddenSidebarItems(value: unknown): HideableSidebarItemId[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
|
||||
|
||||
@@ -8,284 +8,258 @@
|
||||
import { z } from "zod";
|
||||
import { COMBO_CONFIG_MODES } from "@/shared/constants/comboConfigMode";
|
||||
import { MAX_REQUEST_BODY_LIMIT_MB, MIN_REQUEST_BODY_LIMIT_MB } from "@/shared/constants/bodySize";
|
||||
import { HIDEABLE_SIDEBAR_ITEM_IDS } from "@/shared/constants/sidebarVisibility";
|
||||
import { HIDEABLE_SIDEBAR_ITEM_IDS, SIDEBAR_SECTIONS } from "@/shared/constants/sidebarVisibility";
|
||||
import { ACCOUNT_FALLBACK_STRATEGY_VALUES } from "@/shared/constants/routingStrategies";
|
||||
import { SPAWN_CAPABLE_PREFIXES } from "@/server/authz/routeGuard";
|
||||
|
||||
const signatureCacheModeValues = ["enabled", "bypass", "bypass-strict"] as const;
|
||||
|
||||
export const updateSettingsSchema = z
|
||||
.object({
|
||||
newPassword: z.string().min(1).max(200).optional(),
|
||||
currentPassword: z.string().max(200).optional(),
|
||||
theme: z.string().max(50).optional(),
|
||||
language: z.string().max(10).optional(),
|
||||
requireLogin: z.boolean().optional(),
|
||||
enableSocks5Proxy: z.boolean().optional(),
|
||||
instanceName: z.string().max(100).optional(),
|
||||
customLogoUrl: z.string().max(2000).optional(),
|
||||
customLogoBase64: z.string().max(100000).optional(),
|
||||
customFaviconUrl: z.string().max(2000).optional(),
|
||||
customFaviconBase64: z.string().max(50000).optional(),
|
||||
corsOrigins: z.string().max(500).optional(),
|
||||
cloudUrl: z.string().max(500).optional(),
|
||||
baseUrl: z.string().max(500).optional(),
|
||||
setupComplete: z.boolean().optional(),
|
||||
blockedProviders: z.array(z.string().max(100)).optional(),
|
||||
hideHealthCheckLogs: z.boolean().optional(),
|
||||
hideEndpointCloudflaredTunnel: z.boolean().optional(),
|
||||
hideEndpointTailscaleFunnel: z.boolean().optional(),
|
||||
hideEndpointNgrokTunnel: z.boolean().optional(),
|
||||
debugMode: z.boolean().optional(),
|
||||
hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(),
|
||||
comboConfigMode: z.enum(COMBO_CONFIG_MODES).optional(),
|
||||
codexServiceTier: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
tier: z.enum(["default", "priority", "flex"]).optional(),
|
||||
supportedModels: z.array(z.string().max(200)).max(200).optional(),
|
||||
})
|
||||
.optional(),
|
||||
// Claude Fast Mode: opt-in toggle that asks a paired CLIProxyAPI build
|
||||
// (claude-fastmode-spoof) to rewrite SDK-shaped entrypoints so requests can
|
||||
// reach Anthropic Fast Mode (speed:"fast"). Default off; only the listed
|
||||
// Opus models are gated by the Anthropic binary KT() check. Schema is
|
||||
// intentionally permissive on supportedModels so additional eligible model
|
||||
// ids can be enabled without a schema bump.
|
||||
claudeFastMode: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
supportedModels: z.array(z.string().max(200)).max(200).optional(),
|
||||
})
|
||||
.optional(),
|
||||
// Routing settings (#134)
|
||||
fallbackStrategy: z.enum(ACCOUNT_FALLBACK_STRATEGY_VALUES).optional(),
|
||||
wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(),
|
||||
stickyRoundRobinLimit: z.number().int().min(0).max(1000).optional(),
|
||||
requestRetry: z.number().int().min(0).max(10).optional(),
|
||||
maxRetryIntervalSec: z.number().int().min(0).max(300).optional(),
|
||||
maxBodySizeMb: z
|
||||
.number()
|
||||
.int()
|
||||
.min(MIN_REQUEST_BODY_LIMIT_MB)
|
||||
.max(MAX_REQUEST_BODY_LIMIT_MB)
|
||||
.optional(),
|
||||
// Auto intent classifier settings (multilingual routing)
|
||||
intentDetectionEnabled: z.boolean().optional(),
|
||||
intentSimpleMaxWords: z.number().int().min(1).max(500).optional(),
|
||||
intentExtraCodeKeywords: z.array(z.string().max(100)).optional(),
|
||||
intentExtraReasoningKeywords: z.array(z.string().max(100)).optional(),
|
||||
intentExtraSimpleKeywords: z.array(z.string().max(100)).optional(),
|
||||
// Protocol toggles (default: disabled)
|
||||
mcpEnabled: z.boolean().optional(),
|
||||
mcpTransport: z.enum(["stdio", "sse", "streamable-http"]).optional(),
|
||||
a2aEnabled: z.boolean().optional(),
|
||||
wsAuth: z.boolean().optional(),
|
||||
// CLI Fingerprint compatibility (per-provider)
|
||||
cliCompatProviders: z.array(z.string().max(100)).optional(),
|
||||
// CC bridge transforms (issue #2260): config-driven pipeline that normalizes
|
||||
// system blocks at the Claude Code bridge so any client (OpenCode, Cline,
|
||||
// Cursor, Continue, raw API) ends up with classifier-correct structure.
|
||||
ccBridgeTransforms: z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
pipeline: z
|
||||
.array(
|
||||
z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("drop_paragraph_if_contains"),
|
||||
needles: z.array(z.string().max(500)).max(50),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("drop_paragraph_if_starts_with"),
|
||||
prefixes: z.array(z.string().max(500)).max(50),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("replace_text"),
|
||||
match: z.string().min(1).max(500),
|
||||
replacement: z.string().max(500),
|
||||
allOccurrences: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("replace_regex"),
|
||||
pattern: z.string().min(1).max(500),
|
||||
flags: z.string().max(10).optional(),
|
||||
replacement: z.string().max(500),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("drop_block_if_contains"),
|
||||
needles: z.array(z.string().max(500)).max(50),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("prepend_system_block"),
|
||||
text: z.string().min(1).max(2000),
|
||||
idempotencyKey: z.string().max(100).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("append_system_block"),
|
||||
text: z.string().min(1).max(2000),
|
||||
idempotencyKey: z.string().max(100).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("inject_billing_header"),
|
||||
entrypoint: z.string().min(1).max(50),
|
||||
versionFormat: z.enum(["ex-machina", "omniroute-daystamp"]),
|
||||
cchAlgo: z.enum(["sha256-first-user", "xxhash64-body", "static-zero"]),
|
||||
version: z.string().max(50).optional(),
|
||||
}),
|
||||
])
|
||||
)
|
||||
.max(50),
|
||||
})
|
||||
.optional(),
|
||||
// System Transforms (issue #2260 v2): generic per-provider DSL covering
|
||||
// native `claude`, `anthropic-compatible-cc-*` bridge, and any other
|
||||
// provider key. Adds `obfuscate_words` op kind on top of the base set.
|
||||
systemTransforms: z
|
||||
.object({
|
||||
providers: z.record(
|
||||
z.string().max(100),
|
||||
z.object({
|
||||
enabled: z.boolean(),
|
||||
pipeline: z
|
||||
.array(
|
||||
z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("drop_paragraph_if_contains"),
|
||||
needles: z.array(z.string().max(500)).max(50),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("drop_paragraph_if_starts_with"),
|
||||
prefixes: z.array(z.string().max(500)).max(50),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("replace_text"),
|
||||
match: z.string().min(1).max(500),
|
||||
replacement: z.string().max(500),
|
||||
allOccurrences: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("replace_regex"),
|
||||
pattern: z.string().min(1).max(500),
|
||||
flags: z.string().max(10).optional(),
|
||||
replacement: z.string().max(500),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("drop_block_if_contains"),
|
||||
needles: z.array(z.string().max(500)).max(50),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("prepend_system_block"),
|
||||
text: z.string().min(1).max(2000),
|
||||
idempotencyKey: z.string().max(100).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("append_system_block"),
|
||||
text: z.string().min(1).max(2000),
|
||||
idempotencyKey: z.string().max(100).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("inject_billing_header"),
|
||||
entrypoint: z.string().min(1).max(50),
|
||||
versionFormat: z.enum(["ex-machina", "omniroute-daystamp"]),
|
||||
cchAlgo: z.enum(["sha256-first-user", "xxhash64-body", "static-zero"]),
|
||||
version: z.string().max(50).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("obfuscate_words"),
|
||||
words: z.array(z.string().max(100)).max(200),
|
||||
targets: z
|
||||
.array(z.enum(["system", "messages", "tools"]))
|
||||
.max(3)
|
||||
.optional(),
|
||||
}),
|
||||
])
|
||||
)
|
||||
.max(50),
|
||||
})
|
||||
),
|
||||
})
|
||||
.optional(),
|
||||
// Strip provider/model prefix at proxy layer (e.g. "openai/gpt-4" → "gpt-4")
|
||||
stripModelPrefix: z.boolean().optional(),
|
||||
// Cache control preservation mode
|
||||
alwaysPreserveClientCache: z.enum(["auto", "always", "never"]).optional(),
|
||||
antigravitySignatureCacheMode: z.enum(signatureCacheModeValues).optional(),
|
||||
// Adaptive Volume Routing
|
||||
adaptiveVolumeRouting: z.boolean().optional(),
|
||||
// Usage token buffer — safety margin added to reported prompt/input token counts.
|
||||
// Prevents CLI tools from overrunning context windows. Set to 0 to disable.
|
||||
usageTokenBuffer: z.number().int().min(0).max(50000).optional(),
|
||||
// Custom CLI agent definitions for ACP
|
||||
customAgents: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().max(50),
|
||||
name: z.string().max(100),
|
||||
binary: z.string().max(200),
|
||||
versionCommand: z.string().max(300),
|
||||
providerAlias: z.string().max(50),
|
||||
spawnArgs: z.array(z.string().max(200)),
|
||||
protocol: z.enum(["stdio", "http"]),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
// SkillsMP marketplace API key
|
||||
skillsmpApiKey: z.string().max(200).optional(),
|
||||
// Active skills provider (single source of truth for skills page)
|
||||
skillsProvider: z.enum(["skillsmp", "skillssh"]).optional(),
|
||||
// models.dev sync settings
|
||||
modelsDevSyncEnabled: z.boolean().optional(),
|
||||
modelsDevSyncInterval: z.number().int().min(3600000).max(604800000).optional(),
|
||||
// Vision Bridge settings
|
||||
visionBridgeEnabled: z.boolean().optional(),
|
||||
visionBridgeModel: z.string().max(200).optional(),
|
||||
visionBridgePrompt: z.string().max(5000).optional(),
|
||||
visionBridgeTimeout: z.number().int().min(1000).max(300000).optional(),
|
||||
visionBridgeMaxImages: z.number().int().min(1).max(20).optional(),
|
||||
// Missing settings
|
||||
lkgpEnabled: z.boolean().optional(),
|
||||
backgroundDegradation: z.unknown().optional(),
|
||||
bruteForceProtection: z.boolean().optional(),
|
||||
// Auto-routing settings
|
||||
autoRoutingEnabled: z.boolean().optional(),
|
||||
autoRoutingDefaultVariant: z
|
||||
.enum(["lkgp", "coding", "fast", "cheap", "offline", "smart"])
|
||||
.optional(),
|
||||
// LOCAL_ONLY manage-scope bypass policy (T-011). Kill-switch + per-prefix
|
||||
// list, both DB-stored and hot-reloaded into `getAuthzBypassSnapshot()` on
|
||||
// each PATCH. The prefix list MUST NOT include any spawn-capable path —
|
||||
// enforced here and at runtime (GHSA-fhh6-4qxv-rpqj rationale).
|
||||
localOnlyManageScopeBypassEnabled: z.boolean().optional(),
|
||||
localOnlyManageScopeBypassPrefixes: z.array(z.string().startsWith("/")).max(20).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const prefixes = data.localOnlyManageScopeBypassPrefixes;
|
||||
if (!prefixes) return;
|
||||
for (let i = 0; i < prefixes.length; i++) {
|
||||
const prefix = prefixes[i];
|
||||
// Reject prefixes that are the same as, child of, or PARENT of any
|
||||
// spawn-capable prefix. The parent case catches e.g. `/api/cli-tools/`
|
||||
// — a bypass on the parent would grant non-loopback access to the
|
||||
// spawn-capable `/api/cli-tools/runtime/*` surface via path.startsWith.
|
||||
if (
|
||||
SPAWN_CAPABLE_PREFIXES.some(
|
||||
(spawn) => prefix === spawn || prefix.startsWith(spawn) || spawn.startsWith(prefix)
|
||||
export const updateSettingsSchema = z.object({
|
||||
newPassword: z.string().min(1).max(200).optional(),
|
||||
currentPassword: z.string().max(200).optional(),
|
||||
theme: z.string().max(50).optional(),
|
||||
language: z.string().max(10).optional(),
|
||||
requireLogin: z.boolean().optional(),
|
||||
enableSocks5Proxy: z.boolean().optional(),
|
||||
instanceName: z.string().max(100).optional(),
|
||||
customLogoUrl: z.string().max(2000).optional(),
|
||||
customLogoBase64: z.string().max(100000).optional(),
|
||||
customFaviconUrl: z.string().max(2000).optional(),
|
||||
customFaviconBase64: z.string().max(50000).optional(),
|
||||
corsOrigins: z.string().max(500).optional(),
|
||||
cloudUrl: z.string().max(500).optional(),
|
||||
baseUrl: z.string().max(500).optional(),
|
||||
setupComplete: z.boolean().optional(),
|
||||
blockedProviders: z.array(z.string().max(100)).optional(),
|
||||
hideHealthCheckLogs: z.boolean().optional(),
|
||||
hideEndpointCloudflaredTunnel: z.boolean().optional(),
|
||||
hideEndpointTailscaleFunnel: z.boolean().optional(),
|
||||
hideEndpointNgrokTunnel: z.boolean().optional(),
|
||||
debugMode: z.boolean().optional(),
|
||||
hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(),
|
||||
sidebarSectionOrder: z
|
||||
.array(z.enum(SIDEBAR_SECTIONS.map((s) => s.id) as [string, ...string[]]))
|
||||
.optional(),
|
||||
sidebarItemOrder: z.record(z.string(), z.array(z.string().max(100))).optional(),
|
||||
sidebarActivePreset: z.enum(["all", "minimal", "developer", "admin"]).nullable().optional(),
|
||||
comboConfigMode: z.enum(COMBO_CONFIG_MODES).optional(),
|
||||
codexServiceTier: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
tier: z.enum(["default", "priority", "flex"]).optional(),
|
||||
supportedModels: z.array(z.string().max(200)).max(200).optional(),
|
||||
})
|
||||
.optional(),
|
||||
// Claude Fast Mode: opt-in toggle that asks a paired CLIProxyAPI build
|
||||
// (claude-fastmode-spoof) to rewrite SDK-shaped entrypoints so requests can
|
||||
// reach Anthropic Fast Mode (speed:"fast"). Default off; only the listed
|
||||
// Opus models are gated by the Anthropic binary KT() check. Schema is
|
||||
// intentionally permissive on supportedModels so additional eligible model
|
||||
// ids can be enabled without a schema bump.
|
||||
claudeFastMode: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
supportedModels: z.array(z.string().max(200)).max(200).optional(),
|
||||
})
|
||||
.optional(),
|
||||
// Routing settings (#134)
|
||||
fallbackStrategy: z.enum(ACCOUNT_FALLBACK_STRATEGY_VALUES).optional(),
|
||||
wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(),
|
||||
stickyRoundRobinLimit: z.number().int().min(0).max(1000).optional(),
|
||||
requestRetry: z.number().int().min(0).max(10).optional(),
|
||||
maxRetryIntervalSec: z.number().int().min(0).max(300).optional(),
|
||||
maxBodySizeMb: z
|
||||
.number()
|
||||
.int()
|
||||
.min(MIN_REQUEST_BODY_LIMIT_MB)
|
||||
.max(MAX_REQUEST_BODY_LIMIT_MB)
|
||||
.optional(),
|
||||
// Auto intent classifier settings (multilingual routing)
|
||||
intentDetectionEnabled: z.boolean().optional(),
|
||||
intentSimpleMaxWords: z.number().int().min(1).max(500).optional(),
|
||||
intentExtraCodeKeywords: z.array(z.string().max(100)).optional(),
|
||||
intentExtraReasoningKeywords: z.array(z.string().max(100)).optional(),
|
||||
intentExtraSimpleKeywords: z.array(z.string().max(100)).optional(),
|
||||
// Protocol toggles (default: disabled)
|
||||
mcpEnabled: z.boolean().optional(),
|
||||
mcpTransport: z.enum(["stdio", "sse", "streamable-http"]).optional(),
|
||||
a2aEnabled: z.boolean().optional(),
|
||||
wsAuth: z.boolean().optional(),
|
||||
// CLI Fingerprint compatibility (per-provider)
|
||||
cliCompatProviders: z.array(z.string().max(100)).optional(),
|
||||
// CC bridge transforms (issue #2260): config-driven pipeline that normalizes
|
||||
// system blocks at the Claude Code bridge so any client (OpenCode, Cline,
|
||||
// Cursor, Continue, raw API) ends up with classifier-correct structure.
|
||||
ccBridgeTransforms: z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
pipeline: z
|
||||
.array(
|
||||
z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("drop_paragraph_if_contains"),
|
||||
needles: z.array(z.string().max(500)).max(50),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("drop_paragraph_if_starts_with"),
|
||||
prefixes: z.array(z.string().max(500)).max(50),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("replace_text"),
|
||||
match: z.string().min(1).max(500),
|
||||
replacement: z.string().max(500),
|
||||
allOccurrences: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("replace_regex"),
|
||||
pattern: z.string().min(1).max(500),
|
||||
flags: z.string().max(10).optional(),
|
||||
replacement: z.string().max(500),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("drop_block_if_contains"),
|
||||
needles: z.array(z.string().max(500)).max(50),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("prepend_system_block"),
|
||||
text: z.string().min(1).max(2000),
|
||||
idempotencyKey: z.string().max(100).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("append_system_block"),
|
||||
text: z.string().min(1).max(2000),
|
||||
idempotencyKey: z.string().max(100).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("inject_billing_header"),
|
||||
entrypoint: z.string().min(1).max(50),
|
||||
versionFormat: z.enum(["ex-machina", "omniroute-daystamp"]),
|
||||
cchAlgo: z.enum(["sha256-first-user", "xxhash64-body", "static-zero"]),
|
||||
version: z.string().max(50).optional(),
|
||||
}),
|
||||
])
|
||||
)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["localOnlyManageScopeBypassPrefixes", i],
|
||||
message: `BYPASS_PREFIX_NOT_ALLOWED: ${prefix} is spawn-capable and cannot be bypassed`,
|
||||
params: { code: "BYPASS_PREFIX_NOT_ALLOWED", prefix },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
.max(50),
|
||||
})
|
||||
.optional(),
|
||||
// System Transforms (issue #2260 v2): generic per-provider DSL covering
|
||||
// native `claude`, `anthropic-compatible-cc-*` bridge, and any other
|
||||
// provider key. Adds `obfuscate_words` op kind on top of the base set.
|
||||
systemTransforms: z
|
||||
.object({
|
||||
providers: z.record(
|
||||
z.string().max(100),
|
||||
z.object({
|
||||
enabled: z.boolean(),
|
||||
pipeline: z
|
||||
.array(
|
||||
z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("drop_paragraph_if_contains"),
|
||||
needles: z.array(z.string().max(500)).max(50),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("drop_paragraph_if_starts_with"),
|
||||
prefixes: z.array(z.string().max(500)).max(50),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("replace_text"),
|
||||
match: z.string().min(1).max(500),
|
||||
replacement: z.string().max(500),
|
||||
allOccurrences: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("replace_regex"),
|
||||
pattern: z.string().min(1).max(500),
|
||||
flags: z.string().max(10).optional(),
|
||||
replacement: z.string().max(500),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("drop_block_if_contains"),
|
||||
needles: z.array(z.string().max(500)).max(50),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("prepend_system_block"),
|
||||
text: z.string().min(1).max(2000),
|
||||
idempotencyKey: z.string().max(100).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("append_system_block"),
|
||||
text: z.string().min(1).max(2000),
|
||||
idempotencyKey: z.string().max(100).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("inject_billing_header"),
|
||||
entrypoint: z.string().min(1).max(50),
|
||||
versionFormat: z.enum(["ex-machina", "omniroute-daystamp"]),
|
||||
cchAlgo: z.enum(["sha256-first-user", "xxhash64-body", "static-zero"]),
|
||||
version: z.string().max(50).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("obfuscate_words"),
|
||||
words: z.array(z.string().max(100)).max(200),
|
||||
targets: z
|
||||
.array(z.enum(["system", "messages", "tools"]))
|
||||
.max(3)
|
||||
.optional(),
|
||||
}),
|
||||
])
|
||||
)
|
||||
.max(50),
|
||||
})
|
||||
),
|
||||
})
|
||||
.optional(),
|
||||
// Strip provider/model prefix at proxy layer (e.g. "openai/gpt-4" → "gpt-4")
|
||||
stripModelPrefix: z.boolean().optional(),
|
||||
// Cache control preservation mode
|
||||
alwaysPreserveClientCache: z.enum(["auto", "always", "never"]).optional(),
|
||||
antigravitySignatureCacheMode: z.enum(signatureCacheModeValues).optional(),
|
||||
// Adaptive Volume Routing
|
||||
adaptiveVolumeRouting: z.boolean().optional(),
|
||||
// Usage token buffer — safety margin added to reported prompt/input token counts.
|
||||
// Prevents CLI tools from overrunning context windows. Set to 0 to disable.
|
||||
usageTokenBuffer: z.number().int().min(0).max(50000).optional(),
|
||||
// Custom CLI agent definitions for ACP
|
||||
customAgents: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().max(50),
|
||||
name: z.string().max(100),
|
||||
binary: z.string().max(200),
|
||||
versionCommand: z.string().max(300),
|
||||
providerAlias: z.string().max(50),
|
||||
spawnArgs: z.array(z.string().max(200)),
|
||||
protocol: z.enum(["stdio", "http"]),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
// SkillsMP marketplace API key
|
||||
skillsmpApiKey: z.string().max(200).optional(),
|
||||
// Active skills provider (single source of truth for skills page)
|
||||
skillsProvider: z.enum(["skillsmp", "skillssh"]).optional(),
|
||||
// models.dev sync settings
|
||||
modelsDevSyncEnabled: z.boolean().optional(),
|
||||
modelsDevSyncInterval: z.number().int().min(3600000).max(604800000).optional(),
|
||||
// Vision Bridge settings
|
||||
visionBridgeEnabled: z.boolean().optional(),
|
||||
visionBridgeModel: z.string().max(200).optional(),
|
||||
visionBridgePrompt: z.string().max(5000).optional(),
|
||||
visionBridgeTimeout: z.number().int().min(1000).max(300000).optional(),
|
||||
visionBridgeMaxImages: z.number().int().min(1).max(20).optional(),
|
||||
// Missing settings
|
||||
lkgpEnabled: z.boolean().optional(),
|
||||
backgroundDegradation: z.unknown().optional(),
|
||||
bruteForceProtection: z.boolean().optional(),
|
||||
// Auto-routing settings
|
||||
autoRoutingEnabled: z.boolean().optional(),
|
||||
autoRoutingDefaultVariant: z
|
||||
.enum(["lkgp", "coding", "fast", "cheap", "offline", "smart"])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const databaseSettingsSchema = z.object(
|
||||
{
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { HideableSidebarItemId } from "@/shared/constants/sidebarVisibility";
|
||||
import type {
|
||||
HideableSidebarItemId,
|
||||
SidebarItemOrder,
|
||||
SidebarPresetId,
|
||||
SidebarSectionId,
|
||||
} from "@/shared/constants/sidebarVisibility";
|
||||
import type { ResilienceSettings } from "@/lib/resilience/settings";
|
||||
import type {
|
||||
AccountFallbackStrategyValue,
|
||||
@@ -28,6 +33,9 @@ export interface Settings {
|
||||
showQuickStartOnHome?: boolean;
|
||||
showProviderTopologyOnHome?: boolean;
|
||||
hiddenSidebarItems?: HideableSidebarItemId[];
|
||||
sidebarSectionOrder?: SidebarSectionId[];
|
||||
sidebarItemOrder?: SidebarItemOrder;
|
||||
sidebarActivePreset?: SidebarPresetId;
|
||||
resilienceSettings?: ResilienceSettings;
|
||||
// LOCAL_ONLY manage-scope bypass policy (DB-stored, hot-reloaded by
|
||||
// `applyRuntimeSettings` → `applyAuthzBypassSection`). The route guard
|
||||
|
||||
152
tests/unit/sidebar-customization.test.ts
Normal file
152
tests/unit/sidebar-customization.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const sidebarVisibility = await import("../../src/shared/constants/sidebarVisibility.ts");
|
||||
|
||||
const {
|
||||
HIDEABLE_SIDEBAR_ITEM_IDS,
|
||||
SIDEBAR_SECTIONS,
|
||||
SIDEBAR_PRESETS,
|
||||
applySectionOrder,
|
||||
applyItemOrder,
|
||||
normalizeHiddenSidebarItems,
|
||||
} = sidebarVisibility;
|
||||
|
||||
// ─── applySectionOrder ────────────────────────────────────────────────────────
|
||||
|
||||
test("applySectionOrder returns original order when order is empty", () => {
|
||||
const sections = [...SIDEBAR_SECTIONS];
|
||||
const result = applySectionOrder(sections, []);
|
||||
assert.deepEqual(
|
||||
result.map((s) => s.id),
|
||||
sections.map((s) => s.id)
|
||||
);
|
||||
});
|
||||
|
||||
test("applySectionOrder reorders sections by provided list", () => {
|
||||
const sections = [...SIDEBAR_SECTIONS].slice(0, 4);
|
||||
const ids = sections.map((s) => s.id) as any[];
|
||||
const reversed = [...ids].reverse();
|
||||
const result = applySectionOrder(sections, reversed);
|
||||
assert.deepEqual(
|
||||
result.map((s) => s.id),
|
||||
reversed
|
||||
);
|
||||
});
|
||||
|
||||
test("applySectionOrder ignores unknown section IDs in order", () => {
|
||||
const sections = [...SIDEBAR_SECTIONS].slice(0, 3);
|
||||
const ids = sections.map((s) => s.id) as any[];
|
||||
const orderWithUnknown = ["totally-unknown-section" as any, ids[1], ids[0], ids[2]];
|
||||
const result = applySectionOrder(sections, orderWithUnknown);
|
||||
// unknown ID is filtered; remaining IDs applied, then the rest appended
|
||||
assert.equal(result[0].id, ids[1]);
|
||||
assert.equal(result[1].id, ids[0]);
|
||||
assert.equal(result[2].id, ids[2]);
|
||||
});
|
||||
|
||||
test("applySectionOrder appends sections not in order list at end", () => {
|
||||
const sections = [...SIDEBAR_SECTIONS].slice(0, 3);
|
||||
const ids = sections.map((s) => s.id) as any[];
|
||||
// Only order the first two
|
||||
const result = applySectionOrder(sections, [ids[2], ids[0]]);
|
||||
assert.equal(result[0].id, ids[2]);
|
||||
assert.equal(result[1].id, ids[0]);
|
||||
assert.equal(result[2].id, ids[1]); // appended at end
|
||||
});
|
||||
|
||||
// ─── applyItemOrder ───────────────────────────────────────────────────────────
|
||||
|
||||
test("applyItemOrder returns original children when order is empty", () => {
|
||||
const section = SIDEBAR_SECTIONS.find((s) => s.id === "omni-proxy")!;
|
||||
const children = [...section.children];
|
||||
const result = applyItemOrder(children, []);
|
||||
assert.deepEqual(result.length, children.length);
|
||||
});
|
||||
|
||||
test("applyItemOrder reorders items by provided list", () => {
|
||||
const section = SIDEBAR_SECTIONS.find((s) => s.id === "help")!;
|
||||
const children = [...section.children] as any[];
|
||||
const ids = children.map((c) => c.id);
|
||||
const reversed = [...ids].reverse();
|
||||
const result = applyItemOrder(children, reversed) as any[];
|
||||
assert.deepEqual(
|
||||
result.map((c) => c.id),
|
||||
reversed
|
||||
);
|
||||
});
|
||||
|
||||
test("applyItemOrder ignores unknown IDs in order list", () => {
|
||||
const section = SIDEBAR_SECTIONS.find((s) => s.id === "help")!;
|
||||
const children = [...section.children] as any[];
|
||||
const ids = children.map((c) => c.id);
|
||||
const orderWithUnknown = ["ghost-item", ids[1], ids[0], ids[2]];
|
||||
const result = applyItemOrder(children, orderWithUnknown) as any[];
|
||||
assert.equal(result[0].id, ids[1]);
|
||||
assert.equal(result[1].id, ids[0]);
|
||||
assert.equal(result[2].id, ids[2]);
|
||||
});
|
||||
|
||||
// ─── SIDEBAR_PRESETS ──────────────────────────────────────────────────────────
|
||||
|
||||
test("SIDEBAR_PRESETS contains all four preset IDs", () => {
|
||||
const ids = SIDEBAR_PRESETS.map((p) => p.id);
|
||||
assert.ok(ids.includes("all"), "expected 'all' preset");
|
||||
assert.ok(ids.includes("minimal"), "expected 'minimal' preset");
|
||||
assert.ok(ids.includes("developer"), "expected 'developer' preset");
|
||||
assert.ok(ids.includes("admin"), "expected 'admin' preset");
|
||||
});
|
||||
|
||||
test("SIDEBAR_PRESETS all preset hiddenItems are valid HIDEABLE_SIDEBAR_ITEM_IDS", () => {
|
||||
const validIds = new Set(HIDEABLE_SIDEBAR_ITEM_IDS);
|
||||
for (const preset of SIDEBAR_PRESETS) {
|
||||
for (const id of preset.hiddenItems) {
|
||||
assert.ok(validIds.has(id as any), `Preset '${preset.id}' contains invalid item ID: '${id}'`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("SIDEBAR_PRESETS 'all' preset has no hidden items", () => {
|
||||
const allPreset = SIDEBAR_PRESETS.find((p) => p.id === "all");
|
||||
assert.ok(allPreset, "expected 'all' preset to exist");
|
||||
assert.deepEqual(allPreset.hiddenItems, []);
|
||||
});
|
||||
|
||||
test("SIDEBAR_PRESETS non-all presets have at least one hidden item", () => {
|
||||
for (const preset of SIDEBAR_PRESETS.filter((p) => p.id !== "all")) {
|
||||
assert.ok(preset.hiddenItems.length > 0, `Preset '${preset.id}' should hide at least one item`);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── settings-sidebar ID ─────────────────────────────────────────────────────
|
||||
|
||||
test("settings-sidebar is in HIDEABLE_SIDEBAR_ITEM_IDS", () => {
|
||||
assert.ok(
|
||||
HIDEABLE_SIDEBAR_ITEM_IDS.includes("settings-sidebar" as any),
|
||||
"settings-sidebar should be hideable"
|
||||
);
|
||||
});
|
||||
|
||||
test("settings-sidebar item is present in configuration section", () => {
|
||||
const configSection = SIDEBAR_SECTIONS.find((s) => s.id === "configuration");
|
||||
assert.ok(configSection, "configuration section should exist");
|
||||
const items = configSection.children.flatMap((c) =>
|
||||
"type" in c && c.type === "group" ? c.items : [c as any]
|
||||
);
|
||||
assert.ok(
|
||||
items.some((item) => item.id === "settings-sidebar"),
|
||||
"settings-sidebar should be in configuration section children"
|
||||
);
|
||||
});
|
||||
|
||||
// ─── normalizeHiddenSidebarItems ─────────────────────────────────────────────
|
||||
|
||||
test("normalizeHiddenSidebarItems accepts settings-sidebar", () => {
|
||||
const result = normalizeHiddenSidebarItems(["settings-sidebar"]);
|
||||
assert.deepEqual(result, ["settings-sidebar"]);
|
||||
});
|
||||
|
||||
test("normalizeHiddenSidebarItems drops unknown IDs", () => {
|
||||
const result = normalizeHiddenSidebarItems(["settings-sidebar", "ghost-id-xyz"]);
|
||||
assert.deepEqual(result, ["settings-sidebar"]);
|
||||
});
|
||||
Reference in New Issue
Block a user