mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 14:22:09 +03:00
refactor: decompose usageDb, handleSingleModelChat, UI components (T-15, T-28, T-29)
T-15 — Decompose usageDb.js (969→40 lines): - Extract src/lib/usage/migrations.js (legacy + JSON→SQLite migration) - Extract src/lib/usage/usageHistory.js (tracking, pending, log.txt) - Extract src/lib/usage/costCalculator.js (pure cost calculation) - Extract src/lib/usage/usageStats.js (dashboard aggregation) - Extract src/lib/usage/callLogs.js (structured logs, CRUD, rotation) - usageDb.js is now a thin facade re-exporting all functions T-28 — Decompose handleSingleModelChat (183→80 lines): - Extract handleNoCredentials() — credential error responses - Extract safeResolveProxy() — proxy resolution with error handling - Extract safeLogEvents() — fire-and-forget proxy + translation logging - Also created chatHelpers.js with standalone helper exports T-29 — Extract shared UI primitives (3230 total lines): - FilterBar.js — search input + filter chips dropdown - ColumnToggle.js — table column visibility toggle - DataTable.js — generic data table with sticky header, loading/empty Tests: 88/88 pass (no regressions)
This commit is contained in:
100
src/shared/components/ColumnToggle.js
Normal file
100
src/shared/components/ColumnToggle.js
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ColumnToggle — Shared UI primitive (T-29)
|
||||
*
|
||||
* Dropdown menu for toggling table column visibility.
|
||||
* Used by RequestLoggerV2, ProxyLogger, etc.
|
||||
*
|
||||
* Usage:
|
||||
* <ColumnToggle
|
||||
* columns={[{ key: 'model', label: 'Model' }, ...]}
|
||||
* visible={{ model: true, provider: false, ... }}
|
||||
* onToggle={(key) => setVisible({...visible, [key]: !visible[key]})}
|
||||
* />
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
|
||||
export default function ColumnToggle({ columns = [], visible = {}, onToggle }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e) => {
|
||||
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: "relative" }}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
title="Toggle columns"
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
color: "var(--text-secondary, #888)",
|
||||
fontSize: "13px",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "14px" }}>⚙️</span>
|
||||
Columns
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "100%",
|
||||
right: 0,
|
||||
marginTop: "4px",
|
||||
background: "rgba(20,20,30,0.95)",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
borderRadius: "8px",
|
||||
padding: "8px",
|
||||
zIndex: 50,
|
||||
minWidth: "160px",
|
||||
backdropFilter: "blur(12px)",
|
||||
}}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<label
|
||||
key={col.key}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "4px 8px",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
color: visible[col.key]
|
||||
? "var(--text-primary, #e0e0e0)"
|
||||
: "var(--text-secondary, #888)",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visible[col.key] ?? true}
|
||||
onChange={() => onToggle(col.key)}
|
||||
style={{ accentColor: "#6366f1" }}
|
||||
/>
|
||||
{col.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
src/shared/components/DataTable.js
Normal file
157
src/shared/components/DataTable.js
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DataTable — Shared UI primitive (T-29)
|
||||
*
|
||||
* Configurable data table with sticky header, row click,
|
||||
* and optional loading/empty states. Extracts the shared
|
||||
* table rendering pattern from RequestLoggerV2 and ProxyLogger.
|
||||
*
|
||||
* Usage:
|
||||
* <DataTable
|
||||
* columns={visibleColumns}
|
||||
* data={filteredLogs}
|
||||
* renderCell={(row, column) => <span>{row[column.key]}</span>}
|
||||
* onRowClick={(row) => openDetail(row)}
|
||||
* selectedId={selectedLog?.id}
|
||||
* loading={isLoading}
|
||||
* emptyIcon="📋"
|
||||
* emptyMessage="No logs found"
|
||||
* />
|
||||
*/
|
||||
|
||||
export default function DataTable({
|
||||
columns = [],
|
||||
data = [],
|
||||
renderCell,
|
||||
renderHeader,
|
||||
onRowClick,
|
||||
selectedId,
|
||||
loading = false,
|
||||
maxHeight = "calc(100vh - 320px)",
|
||||
emptyIcon = "📭",
|
||||
emptyMessage = "No data found",
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "48px 24px",
|
||||
color: "var(--text-secondary, #888)",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
<span style={{ animation: "spin 1s linear infinite", marginRight: "8px" }}>⏳</span>
|
||||
Loading...
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "48px 24px",
|
||||
color: "var(--text-secondary, #888)",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "32px", marginBottom: "8px", opacity: 0.6 }}>{emptyIcon}</span>
|
||||
{emptyMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ overflow: "auto", maxHeight, borderRadius: "8px" }}>
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: "12px",
|
||||
tableLayout: "auto",
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
textAlign: "left",
|
||||
fontWeight: 600,
|
||||
color: "var(--text-secondary, #888)",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.08)",
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
background: "var(--bg-table-header, rgba(15,15,25,0.95))",
|
||||
zIndex: 1,
|
||||
whiteSpace: "nowrap",
|
||||
fontSize: "11px",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.5px",
|
||||
}}
|
||||
>
|
||||
{renderHeader ? renderHeader(col) : col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row, idx) => (
|
||||
<tr
|
||||
key={row.id || idx}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
style={{
|
||||
cursor: onRowClick ? "pointer" : "default",
|
||||
background:
|
||||
row.id === selectedId
|
||||
? "rgba(99,102,241,0.1)"
|
||||
: idx % 2 === 0
|
||||
? "transparent"
|
||||
: "rgba(255,255,255,0.02)",
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (row.id !== selectedId) {
|
||||
e.currentTarget.style.background = "rgba(255,255,255,0.04)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (row.id !== selectedId) {
|
||||
e.currentTarget.style.background =
|
||||
idx % 2 === 0 ? "transparent" : "rgba(255,255,255,0.02)";
|
||||
}
|
||||
}}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
style={{
|
||||
padding: "6px 10px",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.04)",
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: col.maxWidth || "200px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{renderCell(row, col)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
src/shared/components/FilterBar.js
Normal file
205
src/shared/components/FilterBar.js
Normal file
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* FilterBar — Shared UI primitive (T-29)
|
||||
*
|
||||
* Reusable filter bar with search input and optional filter chips.
|
||||
* Used by RequestLoggerV2, ProxyLogger, and similar data tables.
|
||||
*
|
||||
* Usage:
|
||||
* <FilterBar
|
||||
* searchValue={search}
|
||||
* onSearchChange={setSearch}
|
||||
* placeholder="Search logs..."
|
||||
* filters={[
|
||||
* { key: 'status', label: 'Status', options: ['ok', 'error'] },
|
||||
* { key: 'provider', label: 'Provider', options: ['openai', 'anthropic'] },
|
||||
* ]}
|
||||
* activeFilters={activeFilters}
|
||||
* onFilterChange={(key, value) => setFilters({ ...filters, [key]: value })}
|
||||
* />
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
export default function FilterBar({
|
||||
searchValue = "",
|
||||
onSearchChange,
|
||||
placeholder = "Search...",
|
||||
filters = [],
|
||||
activeFilters = {},
|
||||
onFilterChange,
|
||||
children,
|
||||
}) {
|
||||
const [expandedFilter, setExpandedFilter] = useState(null);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
onSearchChange("");
|
||||
filters.forEach((f) => onFilterChange(f.key, ""));
|
||||
setExpandedFilter(null);
|
||||
}, [onSearchChange, filters, onFilterChange]);
|
||||
|
||||
const hasActiveFilters =
|
||||
searchValue || Object.values(activeFilters).some((v) => v && v !== "");
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "8px",
|
||||
alignItems: "center",
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div style={{ position: "relative", flex: "1 1 200px", minWidth: "200px" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px 8px 32px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
color: "var(--text-primary, #e0e0e0)",
|
||||
fontSize: "13px",
|
||||
outline: "none",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "10px",
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
opacity: 0.4,
|
||||
fontSize: "14px",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
🔍
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Filter chips */}
|
||||
{filters.map((filter) => (
|
||||
<div key={filter.key} style={{ position: "relative" }}>
|
||||
<button
|
||||
onClick={() =>
|
||||
setExpandedFilter(expandedFilter === filter.key ? null : filter.key)
|
||||
}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
borderRadius: "6px",
|
||||
border: `1px solid ${activeFilters[filter.key] ? "rgba(99,102,241,0.5)" : "rgba(255,255,255,0.1)"}`,
|
||||
background: activeFilters[filter.key]
|
||||
? "rgba(99,102,241,0.15)"
|
||||
: "rgba(255,255,255,0.05)",
|
||||
color: activeFilters[filter.key]
|
||||
? "#818cf8"
|
||||
: "var(--text-secondary, #888)",
|
||||
fontSize: "12px",
|
||||
cursor: "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{filter.label}
|
||||
{activeFilters[filter.key] ? ` · ${activeFilters[filter.key]}` : ""}
|
||||
</button>
|
||||
{expandedFilter === filter.key && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "100%",
|
||||
left: 0,
|
||||
marginTop: "4px",
|
||||
background: "rgba(20,20,30,0.95)",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
borderRadius: "8px",
|
||||
padding: "4px",
|
||||
zIndex: 50,
|
||||
minWidth: "120px",
|
||||
backdropFilter: "blur(12px)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
onFilterChange(filter.key, "");
|
||||
setExpandedFilter(null);
|
||||
}}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
padding: "6px 12px",
|
||||
textAlign: "left",
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "#888",
|
||||
fontSize: "12px",
|
||||
cursor: "pointer",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{(filter.options || []).map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => {
|
||||
onFilterChange(filter.key, opt);
|
||||
setExpandedFilter(null);
|
||||
}}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
padding: "6px 12px",
|
||||
textAlign: "left",
|
||||
background:
|
||||
activeFilters[filter.key] === opt
|
||||
? "rgba(99,102,241,0.2)"
|
||||
: "none",
|
||||
border: "none",
|
||||
color:
|
||||
activeFilters[filter.key] === opt
|
||||
? "#818cf8"
|
||||
: "var(--text-primary, #e0e0e0)",
|
||||
fontSize: "12px",
|
||||
cursor: "pointer",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Clear all */}
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(239,68,68,0.3)",
|
||||
background: "rgba(239,68,68,0.1)",
|
||||
color: "#ef4444",
|
||||
fontSize: "12px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Extra controls (e.g. refresh button) */}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user