perf: server-side search + pagination for topics (30 per page)
Loads only 30 topics at a time with server-side search instead of fetching all 6000+ topics. Debounced search input, pagination, selected-only filter. Much faster for large Victron brokers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
68174d16c2
commit
e6b366862c
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
|
|
||||||
@ -13,37 +13,57 @@ interface Topic {
|
|||||||
discovered_at: string;
|
discovered_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TopicsResponse {
|
||||||
|
topics: Topic[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 30;
|
||||||
|
|
||||||
export default function MqttTopics() {
|
export default function MqttTopics() {
|
||||||
const { brokerId } = useParams<{ brokerId: string }>();
|
const { brokerId } = useParams<{ brokerId: string }>();
|
||||||
const [topics, setTopics] = useState<Topic[]>([]);
|
const [data, setData] = useState<TopicsResponse>({ topics: [], total: 0 });
|
||||||
const [filter, setFilter] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
const [showSelected, setShowSelected] = useState(false);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
const [prefix, setPrefix] = useState('');
|
const [prefix, setPrefix] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [discoveryMsg, setDiscoveryMsg] = useState('');
|
const [discoveryMsg, setDiscoveryMsg] = useState('');
|
||||||
const [editPaths, setEditPaths] = useState<Record<number, string>>({});
|
const [editPaths, setEditPaths] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
async function load() {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await api.get<Topic[]>(`/api/mqtt/brokers/${brokerId}/topics`);
|
const params = new URLSearchParams();
|
||||||
setTopics(data);
|
if (search) params.set('search', search);
|
||||||
|
if (showSelected) params.set('selected', '1');
|
||||||
|
params.set('limit', String(PAGE_SIZE));
|
||||||
|
params.set('offset', String(page * PAGE_SIZE));
|
||||||
|
const result = await api.get<TopicsResponse>(`/api/mqtt/brokers/${brokerId}/topics?${params}`);
|
||||||
|
setData(result);
|
||||||
const paths: Record<number, string> = {};
|
const paths: Record<number, string> = {};
|
||||||
data.forEach((t) => { paths[t.id] = t.json_path ?? ''; });
|
result.topics.forEach((t) => { paths[t.id] = t.json_path ?? ''; });
|
||||||
setEditPaths(paths);
|
setEditPaths((prev) => ({ ...prev, ...paths }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message);
|
setError((e as Error).message);
|
||||||
}
|
}
|
||||||
}
|
}, [brokerId, search, showSelected, page]);
|
||||||
|
|
||||||
useEffect(() => { load(); }, [brokerId]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => { setSearch(searchInput); setPage(0); }, 400);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchInput]);
|
||||||
|
|
||||||
async function handleDiscover() {
|
async function handleDiscover() {
|
||||||
setDiscoveryMsg('');
|
setDiscoveryMsg('');
|
||||||
try {
|
try {
|
||||||
await api.post(`/api/mqtt/brokers/${brokerId}/topics/discover`, { prefix: prefix || null });
|
await api.post(`/api/mqtt/brokers/${brokerId}/topics/discover`, { prefix: prefix || null });
|
||||||
setDiscoveryMsg('Discovery laeuft 30 Sekunden... Liste wird automatisch aktualisiert.');
|
setDiscoveryMsg('Discovery laeuft 30s...');
|
||||||
// Refresh every 3 seconds during the 30s discovery window
|
const interval = setInterval(() => load(), 5000);
|
||||||
const interval = setInterval(() => load(), 3000);
|
setTimeout(() => { clearInterval(interval); setDiscoveryMsg(''); load(); }, 32000);
|
||||||
setTimeout(() => { clearInterval(interval); setDiscoveryMsg('Discovery abgeschlossen.'); load(); }, 32000);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message);
|
setError((e as Error).message);
|
||||||
}
|
}
|
||||||
@ -58,9 +78,9 @@ export default function MqttTopics() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleIgnore(id: number, ignored: number) {
|
async function handleIgnore(id: number) {
|
||||||
try {
|
try {
|
||||||
await api.put(`/api/mqtt/brokers/${brokerId}/topics/${id}`, { ignored: ignored ? 0 : 1 });
|
await api.put(`/api/mqtt/brokers/${brokerId}/topics/${id}`, { ignored: 1 });
|
||||||
load();
|
load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message);
|
setError((e as Error).message);
|
||||||
@ -75,88 +95,92 @@ export default function MqttTopics() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = topics.filter((t) =>
|
const totalPages = Math.ceil(data.total / PAGE_SIZE);
|
||||||
!t.ignored && t.topic.toLowerCase().includes(filter.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold text-gray-800 mb-2">MQTT Topics</h2>
|
<h2 className="text-xl font-semibold text-gray-800 mb-2">MQTT Topics</h2>
|
||||||
<p className="text-sm text-gray-500 mb-6">Broker ID: {brokerId}</p>
|
<p className="text-sm text-gray-500 mb-4">Broker #{brokerId} — {data.total} Topics gefunden</p>
|
||||||
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">{error}</div>}
|
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">{error}<button className="ml-2" onClick={() => setError('')}>x</button></div>}
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="flex gap-3 mb-6 flex-wrap">
|
<div className="flex gap-3 mb-4 flex-wrap items-center">
|
||||||
<input
|
<input
|
||||||
placeholder="Filter Topics..."
|
placeholder="Topics suchen (z.B. grid, battery, pv)..."
|
||||||
className="border border-gray-300 rounded px-3 py-1.5 text-sm w-56"
|
className="border border-gray-300 rounded px-3 py-1.5 text-sm w-72"
|
||||||
value={filter}
|
value={searchInput}
|
||||||
onChange={(e) => setFilter(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={showSelected} onChange={(e) => { setShowSelected(e.target.checked); setPage(0); }} className="accent-green-500" />
|
||||||
|
Nur ausgewaehlte
|
||||||
|
</label>
|
||||||
|
<div className="flex-1" />
|
||||||
<input
|
<input
|
||||||
placeholder="Discovery-Praefix (z.B. sensor/)"
|
placeholder="Discovery-Prefix (z.B. N/)"
|
||||||
className="border border-gray-300 rounded px-3 py-1.5 text-sm w-64"
|
className="border border-gray-300 rounded px-3 py-1.5 text-sm w-48"
|
||||||
value={prefix}
|
value={prefix}
|
||||||
onChange={(e) => setPrefix(e.target.value)}
|
onChange={(e) => setPrefix(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button onClick={handleDiscover} className="bg-indigo-500 hover:bg-indigo-600 text-white text-sm px-4 py-1.5 rounded">
|
||||||
onClick={handleDiscover}
|
Discovery
|
||||||
className="bg-indigo-500 hover:bg-indigo-600 text-white text-sm px-4 py-1.5 rounded transition-colors"
|
|
||||||
>
|
|
||||||
Discovery starten
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{discoveryMsg && <p className="text-sm text-indigo-600 mb-4">{discoveryMsg}</p>}
|
{discoveryMsg && <p className="text-sm text-indigo-600 mb-3">{discoveryMsg}</p>}
|
||||||
|
|
||||||
|
{/* Topic list */}
|
||||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase w-10">Aktiv</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 w-10">Aktiv</th>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Topic</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Topic</th>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Letzter Wert</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 w-48">Letzter Wert</th>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">JSON-Pfad</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 w-36">JSON-Pfad</th>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Aktion</th>
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 w-20">Aktion</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-gray-100">
|
||||||
{filtered.length === 0 && (
|
{data.topics.length === 0 && (
|
||||||
<tr><td colSpan={5} className="px-4 py-6 text-center text-gray-400">Keine Topics gefunden</td></tr>
|
<tr><td colSpan={5} className="px-3 py-6 text-center text-gray-400">
|
||||||
|
{search ? 'Keine Topics fuer diese Suche' : 'Keine Topics gefunden. Starte Discovery.'}
|
||||||
|
</td></tr>
|
||||||
)}
|
)}
|
||||||
{filtered.map((t) => (
|
{data.topics.map((t) => (
|
||||||
<tr key={t.id} className={`hover:bg-gray-50 ${t.selected ? 'bg-green-50' : ''}`}>
|
<tr key={t.id} className={`hover:bg-gray-50 ${t.selected ? 'bg-green-50' : ''}`}>
|
||||||
<td className="px-4 py-2 text-center">
|
<td className="px-3 py-1.5 text-center">
|
||||||
<input
|
<input type="checkbox" checked={!!t.selected}
|
||||||
type="checkbox"
|
|
||||||
checked={!!t.selected}
|
|
||||||
onChange={(e) => handleSelect(t.id, e.target.checked)}
|
onChange={(e) => handleSelect(t.id, e.target.checked)}
|
||||||
className="w-4 h-4 accent-green-500"
|
className="w-4 h-4 accent-green-500 cursor-pointer" />
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 font-mono text-xs text-gray-700">{t.topic}</td>
|
<td className="px-3 py-1.5 font-mono text-xs text-gray-700 truncate max-w-md" title={t.topic}>{t.topic}</td>
|
||||||
<td className="px-4 py-2 text-xs text-gray-500 max-w-xs truncate">{t.last_payload ?? '-'}</td>
|
<td className="px-3 py-1.5 text-xs text-gray-500 truncate max-w-48" title={t.last_payload || ''}>{t.last_payload ?? '—'}</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-3 py-1.5">
|
||||||
<input
|
<input className="border border-gray-300 rounded px-2 py-0.5 text-xs w-full"
|
||||||
className="border border-gray-300 rounded px-2 py-1 text-xs w-40"
|
|
||||||
value={editPaths[t.id] ?? ''}
|
value={editPaths[t.id] ?? ''}
|
||||||
onChange={(e) => setEditPaths({ ...editPaths, [t.id]: e.target.value })}
|
onChange={(e) => setEditPaths({ ...editPaths, [t.id]: e.target.value })}
|
||||||
onBlur={() => handleJsonPathBlur(t.id)}
|
onBlur={() => handleJsonPathBlur(t.id)}
|
||||||
placeholder="$.temperature"
|
placeholder="$.value" />
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-3 py-1.5">
|
||||||
<button
|
<button onClick={() => handleIgnore(t.id)} className="text-xs text-orange-400 hover:text-orange-600">Ignorieren</button>
|
||||||
onClick={() => handleIgnore(t.id, t.ignored)}
|
|
||||||
className="text-xs text-orange-500 hover:text-orange-700"
|
|
||||||
>
|
|
||||||
Ignorieren
|
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center gap-2 mt-3 text-sm text-gray-600">
|
||||||
|
<button onClick={() => setPage(Math.max(0, page - 1))} disabled={page === 0}
|
||||||
|
className="px-3 py-1 rounded border border-gray-300 disabled:opacity-30 hover:bg-gray-50">Zurueck</button>
|
||||||
|
<span>Seite {page + 1} von {totalPages}</span>
|
||||||
|
<button onClick={() => setPage(Math.min(totalPages - 1, page + 1))} disabled={page >= totalPages - 1}
|
||||||
|
className="px-3 py-1 rounded border border-gray-300 disabled:opacity-30 hover:bg-gray-50">Weiter</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,11 +11,15 @@ import { getDb } from '../../db/index';
|
|||||||
|
|
||||||
const router = Router({ mergeParams: true });
|
const router = Router({ mergeParams: true });
|
||||||
|
|
||||||
// GET /api/mqtt/brokers/:brokerId/topics
|
// GET /api/mqtt/brokers/:brokerId/topics?search=grid&selected=1&limit=50&offset=0
|
||||||
router.get('/', (req, res): void => {
|
router.get('/', (req, res): void => {
|
||||||
try {
|
try {
|
||||||
const brokerId = parseInt(req.params.brokerId);
|
const brokerId = parseInt(req.params.brokerId);
|
||||||
res.json(getTopicsForBroker(brokerId));
|
const search = req.query.search as string | undefined;
|
||||||
|
const selectedOnly = req.query.selected === '1';
|
||||||
|
const limit = parseInt(req.query.limit as string) || 50;
|
||||||
|
const offset = parseInt(req.query.offset as string) || 0;
|
||||||
|
res.json(getTopicsForBroker(brokerId, { search, selectedOnly, limit, offset }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: (err as Error).message });
|
res.status(500).json({ error: (err as Error).message });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,11 +19,32 @@ export interface UpdateTopicData {
|
|||||||
ignored?: number;
|
ignored?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTopicsForBroker(brokerId: number): MqttTopic[] {
|
export function getTopicsForBroker(brokerId: number, opts?: { search?: string; selectedOnly?: boolean; limit?: number; offset?: number }): { topics: MqttTopic[]; total: number } {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
return db
|
const conditions = ['broker_id = ?'];
|
||||||
.prepare('SELECT * FROM mqtt_topics WHERE broker_id = ? ORDER BY id ASC')
|
const params: any[] = [brokerId];
|
||||||
.all(brokerId) as MqttTopic[];
|
|
||||||
|
if (opts?.search) {
|
||||||
|
conditions.push('topic LIKE ?');
|
||||||
|
params.push(`%${opts.search}%`);
|
||||||
|
}
|
||||||
|
if (opts?.selectedOnly) {
|
||||||
|
conditions.push('selected = 1');
|
||||||
|
}
|
||||||
|
conditions.push('ignored = 0');
|
||||||
|
|
||||||
|
const where = conditions.join(' AND ');
|
||||||
|
const total = (db.prepare(`SELECT count(*) as c FROM mqtt_topics WHERE ${where}`).get(...params) as any).c;
|
||||||
|
|
||||||
|
const limit = opts?.limit || 50;
|
||||||
|
const offset = opts?.offset || 0;
|
||||||
|
params.push(limit, offset);
|
||||||
|
|
||||||
|
const topics = db
|
||||||
|
.prepare(`SELECT * FROM mqtt_topics WHERE ${where} ORDER BY selected DESC, last_payload IS NOT NULL DESC, topic ASC LIMIT ? OFFSET ?`)
|
||||||
|
.all(...params) as MqttTopic[];
|
||||||
|
|
||||||
|
return { topics, total };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateTopic(id: number, data: UpdateTopicData): MqttTopic | undefined {
|
export function updateTopic(id: number, data: UpdateTopicData): MqttTopic | undefined {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user