Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5a34f38
feat(dashboard): models and combos catalog with filters and health tests
fouadSalkini Sep 26, 2026
a5def88
i18n(dashboard): translate models and combos catalog across all 66 lo…
fouadSalkini Sep 26, 2026
b5206a1
i18n(dashboard): translate every models and combos catalog label
fouadSalkini Sep 26, 2026
a1844f7
fix(dashboard): address catalog review findings
fouadSalkini Sep 26, 2026
96348ad
i18n(dashboard): translate catalog review-fix labels across all 66 lo…
fouadSalkini Sep 26, 2026
c4e121a
docs(changelog): add the models and combos catalog fragment #14887
fouadSalkini Sep 26, 2026
f415a76
fix(dashboard): address catalog re-review findings
fouadSalkini Sep 26, 2026
34739f6
fix(dashboard): make catalog URL sync safe and keep filters during re…
fouadSalkini Sep 26, 2026
f34ca4f
refactor(dashboard): rename the catalog test storage constant for the…
fouadSalkini Sep 26, 2026
5a78dcf
refactor(dashboard): split catalog utils into smaller helpers for the…
fouadSalkini Sep 26, 2026
e7c0b20
refactor(dashboard): split catalog filters, badge and bulk bar into s…
fouadSalkini Sep 26, 2026
1217d82
refactor(dashboard): split catalog tables into smaller helpers for th…
fouadSalkini Sep 26, 2026
e64c4fb
refactor(dashboard): isolate catalog health-test requests
fouadSalkini Sep 26, 2026
38fd553
refactor(dashboard): split catalog test runner lifecycle hooks
fouadSalkini Sep 26, 2026
45ffd66
refactor(dashboard): compose catalog page from bounded hooks and panels
fouadSalkini Sep 26, 2026
94590f0
fix(dashboard): refresh catalog and tolerate denied browser storage
fouadSalkini Oct 2, 2026
5b4cb4f
Merge remote-tracking branch 'origin/release/v3.8.52' into feat/model…
fouadSalkini Oct 5, 2026
ff557c3
fix(i18n): drop the duplicated sidebar catalog keys and use the canon…
fouadSalkini Oct 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/features/14887-models-combos-catalog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **feat(dashboard):** models and combos catalog with filters and browser-side health tests ([#14887](https://github.com/diegosouzapw/OmniRoute/pull/14887)).
115 changes: 115 additions & 0 deletions src/app/(dashboard)/dashboard/models/CatalogBulkActionBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"use client";

import { useTranslations } from "next-intl";
import { Button } from "@/shared/components";
import type { ProgressState } from "./useCatalogTestRunner";

/** The run's progress bar while it runs, then the cancelled notice when it was stopped. */
function CatalogBulkProgress({ running, progress }: { running: boolean; progress: ProgressState }) {
const t = useTranslations("modelCatalog");
const percent = progress.total > 0 ? Math.round((progress.completed / progress.total) * 100) : 0;
const counts = { completed: progress.completed, total: progress.total };
const progressText =
progress.kind === "combos"
? t("testingProgressCombos", counts)
: t("testingProgressModels", counts);
const cancelledText =
progress.kind === "combos"
? t("testsCancelledCombos", counts)
: t("testsCancelledModels", counts);

if (running) {
return (
<div className="flex items-center gap-3">
<div className="h-2 w-32 overflow-hidden rounded-full bg-black/10 dark:bg-white/10 sm:w-48">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${percent}%` }}
role="progressbar"
aria-label={t("bulkTestProgress")}
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
aria-valuetext={progressText}
/>
</div>
<span className="text-xs font-medium text-text-muted" aria-hidden="true">
{progressText}
</span>
</div>
);
}
return progress.cancelled ? (
<p role="status" className="text-xs font-medium text-text-muted">
{cancelledText}
</p>
) : null;
}

export default function CatalogBulkActionBar({
selectedCount,
filteredCount,
running,
progress,
hasTestResults,
onTestSelected,
onTestFiltered,
onCancel,
onClearResults,
}: {
selectedCount: number;
filteredCount: number;
running: boolean;
progress: ProgressState;
hasTestResults: boolean;
onTestSelected: () => void;
onTestFiltered: () => void;
onCancel: () => void;
onClearResults: () => void;
}) {
const t = useTranslations("modelCatalog");

return (
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border bg-black/[0.01] px-4 py-2.5 dark:bg-white/[0.01]">
<div className="flex flex-wrap items-center gap-2">
<Button
variant="secondary"
size="sm"
disabled={selectedCount === 0 || running}
onClick={onTestSelected}
data-testid="test-selected-btn"
>
{t("testSelected", { count: selectedCount })}
</Button>
<Button
variant="secondary"
size="sm"
disabled={filteredCount === 0 || running}
onClick={onTestFiltered}
data-testid="test-all-filtered-btn"
>
{t("testAllFiltered", { count: filteredCount })}
</Button>

{running && (
<Button variant="danger" size="sm" onClick={onCancel} data-testid="cancel-tests-btn">
{t("cancel")}
</Button>
)}

{hasTestResults && !running && (
<Button
variant="ghost"
size="sm"
onClick={onClearResults}
data-testid="clear-test-results-btn"
>
{t("clearResults")}
</Button>
)}
</div>

<CatalogBulkProgress running={running} progress={progress} />
</div>
);
}
152 changes: 152 additions & 0 deletions src/app/(dashboard)/dashboard/models/CatalogFilterFields.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"use client";

import type { ReactNode } from "react";
import { useTranslations } from "next-intl";
import { Button, Input } from "@/shared/components";
import { parseNonNegativeInt } from "./catalogUrlState";

// Shared building blocks of the model and combo filter panels; the markup matches what each
// panel rendered inline, so both keep the same DOM, labels and classes.

const FIELD_LABEL_CLASS = "flex flex-col gap-1 text-xs font-medium text-text-main";
const FIELD_CONTROL_CLASS =
"h-9 w-full rounded-control border border-black/10 bg-white px-2.5 text-xs text-text-main focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary dark:border-white/10 dark:bg-white/5";

function humanize(value: string): string {
return value
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replaceAll(/[_-]+/g, " ")
.replace(/^\w/, (first) => first.toUpperCase());
}

/** Search box, result count and the "clear filters" action above the filter grid. */
export function FilterSearchRow({
label,
placeholder,
query,
onQueryChange,
totalCount,
countLabel,
hasActiveFilters,
onClear,
}: {
label: string;
placeholder: string;
query: string;
onQueryChange: (query: string) => void;
totalCount: number;
countLabel: string;
hasActiveFilters: boolean;
onClear: () => void;
}) {
const t = useTranslations("modelCatalog");

return (
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
<Input
label={label}
icon="search"
placeholder={placeholder}
value={query}
onChange={(event) => onQueryChange(event.target.value)}
className="min-w-0 flex-1"
/>

<div className="flex items-center gap-3">
<span className="text-sm text-text-muted">
{totalCount} {countLabel}
</span>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={onClear}>
{t("clearFilters")}
</Button>
)}
</div>
</div>
);
}

export function FilterSelect({
label,
value,
onChange,
children,
}: {
label: string;
value: string;
onChange: (value: string) => void;
children: ReactNode;
}) {
return (
<label className={FIELD_LABEL_CLASS}>
{label}
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={FIELD_CONTROL_CLASS}
>
{children}
</select>
</label>
);
}

/** One option per value, labelled with the humanized value. */
export function HumanizedOptions({ values }: { values: string[] }) {
return (
<>
{values.map((value) => (
<option key={value} value={value}>
{humanize(value)}
</option>
))}
</>
);
}

/** The health-test result choices shared by the model and combo filters. */
export function TestResultOptions() {
const t = useTranslations("modelCatalog");

return (
<>
<option value="all">{t("allResults")}</option>
<option value="untested">{t("untested")}</option>
<option value="ok">{t("statusOk")}</option>
<option value="slow">{t("statusSlow")}</option>
<option value="error">{t("statusError")}</option>
</>
);
}

/** A numeric filter: empty or invalid input clears it (see `parseNonNegativeInt`). */
export function FilterNumberInput({
label,
min,
step,
placeholder,
value,
onChange,
}: {
label: string;
min: number;
step?: number;
placeholder: string;
value: number | undefined;
onChange: (value: number | undefined) => void;
}) {
return (
<label className={FIELD_LABEL_CLASS}>
{label}
<input
type="number"
min={min}
step={step}
placeholder={placeholder}
value={value ?? ""}
onChange={(e) => onChange(parseNonNegativeInt(e.target.value))}
className={FIELD_CONTROL_CLASS}
/>
</label>
);
}
119 changes: 119 additions & 0 deletions src/app/(dashboard)/dashboard/models/CatalogTableParts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"use client";

import type { ReactNode } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/shared/components";

// Cells and footer shared by the model and combo catalog tables; the markup matches what each
// table rendered inline, so both keep the same DOM, labels and classes.

const CHECKBOX_CLASS =
"rounded border-black/20 text-primary focus:ring-primary dark:border-white/20";

/** A column heading without sorting. */
export function PlainHeading({ children }: { children: ReactNode }) {
return (
<th
scope="col"
className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-text-muted"
>
{children}
</th>
);
}

/** The right-aligned heading above the per-row test buttons. */
export function ActionsHeading() {
const t = useTranslations("modelCatalog");
return (
<th
scope="col"
className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wide text-text-muted"
>
{t("actions")}
</th>
);
}

/** Page-wide selection checkbox; `indeterminate` marks a partly selected page. */
export function SelectAllHeaderCell({
checked,
indeterminate,
onToggle,
label,
}: {
checked: boolean;
indeterminate: boolean;
onToggle: () => void;
label: string;
}) {
return (
<th scope="col" className="w-10 px-4 py-3">
<input
type="checkbox"
checked={checked}
ref={(el) => {
if (el) el.indeterminate = indeterminate;
}}
onChange={onToggle}
aria-label={label}
className={CHECKBOX_CLASS}
/>
</th>
);
}

export function SelectRowCell({
checked,
onToggle,
label,
}: {
checked: boolean;
onToggle: () => void;
label: string;
}) {
return (
<td className="w-10 px-4 py-3">
<input
type="checkbox"
checked={checked}
onChange={onToggle}
aria-label={label}
className={CHECKBOX_CLASS}
/>
</td>
);
}

/** Result summary (the children) plus previous/next paging under a catalog table. */
export function CatalogTableFooter({
page,
pageCount,
onPrevious,
onNext,
children,
}: {
page: number;
pageCount: number;
onPrevious: () => void;
onNext: () => void;
children: ReactNode;
}) {
const t = useTranslations("modelCatalog");
return (
<footer className="flex flex-col gap-3 border-t border-border px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-text-muted" aria-live="polite">
{children}
</p>
<div className="flex items-center gap-3">
<span className="text-sm text-text-muted">{t("page", { page, pageCount })}</span>
<Button variant="secondary" size="sm" disabled={page === 1} onClick={onPrevious}>
{t("previous")}
</Button>
<Button variant="secondary" size="sm" disabled={page >= pageCount} onClick={onNext}>
{t("next")}
</Button>
</div>
</footer>
);
}
Loading
Loading