feat: agent version badges, SHIFT path config and video search per system

- revert vite port to 4173 to match the Synology reverse-proxy upstream
- show agent version chip on each system card (or "legacy")
- SHIFT folder path editor per node, saved to backend
- search videos in a node's SHIFT folder by date/name, fetch from results
This commit is contained in:
2026-07-16 17:32:07 +05:30
parent dc604a4c30
commit 91ad75178a
3 changed files with 439 additions and 58 deletions

339
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -41,6 +41,8 @@ function App() {
// Remote video fetch state
const [fileTransfers, setFileTransfers] = useState({});
const [videoNameInputs, setVideoNameInputs] = useState({});
const [shiftPathInputs, setShiftPathInputs] = useState({});
const [searchInputs, setSearchInputs] = useState({});
// Dispatcher form state
const [selectedClients, setSelectedClients] = useState([]);
@@ -158,8 +160,8 @@ function App() {
}
};
const handleRequestFile = async (clientId) => {
const filename = (videoNameInputs[clientId] || '').trim();
const handleRequestFile = async (clientId, overrideName) => {
const filename = (overrideName || videoNameInputs[clientId] || '').trim();
if (!filename) {
showToast('Enter a video file name first.', 'error');
return;
@@ -181,6 +183,48 @@ function App() {
}
};
const handleSaveShiftPath = async (clientId) => {
const path = (shiftPathInputs[clientId] ?? clients[clientId]?.shift_path ?? '').trim();
try {
const response = await apiFetch(
`${API_BASE}/api/set-shift-path?client_id=${encodeURIComponent(clientId)}&shift_path=${encodeURIComponent(path)}`,
{ method: 'POST' }
);
if (response.ok) {
showToast(`SHIFT path saved for ${clientId}`);
syncFleetData();
} else {
const data = await response.json().catch(() => ({}));
showToast(data.detail || 'Failed to save SHIFT path.', 'error');
}
} catch (err) {
showToast('Cannot connect to RMM backend server.', 'error');
}
};
const handleSearchVideos = async (clientId) => {
const query = (searchInputs[clientId] || '').trim();
if (!query) {
showToast('Enter a search term first (e.g. a date like 20260716).', 'error');
return;
}
try {
const response = await apiFetch(
`${API_BASE}/api/request-search?client_id=${encodeURIComponent(clientId)}&query=${encodeURIComponent(query)}`,
{ method: 'POST' }
);
if (response.ok) {
showToast(`Searching '${query}' on ${clientId}...`);
syncFleetData();
} else {
const data = await response.json().catch(() => ({}));
showToast(data.detail || 'Failed to start search.', 'error');
}
} catch (err) {
showToast('Cannot connect to RMM backend server.', 'error');
}
};
const handleCancelUpload = async (clientId, filename) => {
try {
const response = await apiFetch(
@@ -647,6 +691,9 @@ function App() {
<span className="text-[9px] uppercase tracking-wider font-extrabold px-2 py-0.5 rounded bg-rose-500/10 border border-rose-500/20 text-rose-300/80">
Offline
</span>
<span className="text-[9px] tracking-wider font-extrabold px-2 py-0.5 rounded bg-white/5 border border-white/10 text-slate-500 font-mono">
{info.agent_version ? `v${info.agent_version}` : 'legacy'}
</span>
</div>
<div className="flex items-center justify-between sm:justify-end gap-4">
@@ -699,6 +746,9 @@ function App() {
<span className="text-[9px] uppercase tracking-wider font-extrabold px-2 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/20 text-emerald-300">
Active
</span>
<span className="text-[9px] tracking-wider font-extrabold px-2 py-0.5 rounded bg-violet-500/10 border border-violet-500/20 text-violet-300 font-mono">
{info.agent_version ? `v${info.agent_version}` : 'legacy'}
</span>
</div>
<div className="text-left sm:text-right">
@@ -918,6 +968,108 @@ function App() {
<span className="text-[10px] uppercase font-extrabold tracking-wider text-slate-400 flex items-center gap-1.5 mb-2.5">
<Play className="w-4 h-4 text-violet-400" /> Fetch Video From Node
</span>
{/* SHIFT folder location for this node */}
<div className="flex flex-col sm:flex-row gap-2 mb-2.5">
<div className="flex-1 relative">
<HardDrive className="w-3.5 h-3.5 text-slate-500 absolute left-3 top-2.5" />
<input
type="text"
placeholder="SHIFT folder path, e.g. /mnt/<disk-uuid>/SR/SHIFT"
value={shiftPathInputs[clientId] ?? info.shift_path ?? ''}
onChange={(e) => setShiftPathInputs(prev => ({ ...prev, [clientId]: e.target.value }))}
onKeyDown={(e) => { if (e.key === 'Enter') handleSaveShiftPath(clientId); }}
className="w-full bg-slate-950/60 border border-white/5 rounded-xl pl-9 pr-3 py-2 text-xs text-slate-300 font-mono placeholder-slate-600 focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500/20 transition-all"
/>
</div>
<button
type="button"
onClick={() => handleSaveShiftPath(clientId)}
className="px-4 py-2 rounded-xl text-slate-200 font-bold text-xs flex items-center justify-center gap-1.5 bg-white/5 border border-white/10 hover:bg-white/10 transition-all"
>
<CheckCircle className="w-3.5 h-3.5" />
Save Path
</button>
</div>
{/* Search videos inside the SHIFT folder */}
<div className="flex flex-col sm:flex-row gap-2 mb-2.5">
<div className="flex-1 relative">
<Search className="w-3.5 h-3.5 text-slate-500 absolute left-3 top-2.5" />
<input
type="text"
placeholder="Search SHIFT folder, e.g. 20260716"
value={searchInputs[clientId] || ''}
onChange={(e) => setSearchInputs(prev => ({ ...prev, [clientId]: e.target.value }))}
onKeyDown={(e) => { if (e.key === 'Enter') handleSearchVideos(clientId); }}
className="w-full bg-slate-950/60 border border-white/5 rounded-xl pl-9 pr-3 py-2 text-xs text-slate-300 font-mono placeholder-slate-600 focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500/20 transition-all"
/>
</div>
<button
type="button"
onClick={() => handleSearchVideos(clientId)}
className="px-4 py-2 rounded-xl text-white font-bold text-xs flex items-center justify-center gap-1.5 bg-gradient-to-r from-slate-700 to-slate-600 hover:from-slate-600 hover:to-slate-500 transition-all"
>
<Search className="w-3.5 h-3.5" />
Search
</button>
</div>
{parseFloat(info.agent_version) < 3.3 && (
<p className="text-[10px] text-amber-400/70 mb-2.5 italic">
Search needs agent v3.3-shift run update_agent on this node first.
</p>
)}
{/* Search results */}
{info.file_search && (
<div className="mb-2.5 p-3 rounded-xl bg-slate-950/40 border border-white/5">
<div className="flex items-center justify-between gap-2 mb-1.5">
<span className="text-[10px] font-bold text-slate-400 truncate">
Results for <span className="text-slate-200 font-mono">'{info.file_search.query}'</span>
{info.file_search.searched_path && (
<span className="text-slate-600"> in {info.file_search.searched_path}</span>
)}
</span>
<span className={`text-[9px] uppercase tracking-wider font-extrabold px-2 py-0.5 rounded border shrink-0 flex items-center gap-1 ${
info.file_search.status === 'done'
? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-300'
: info.file_search.status === 'error'
? 'bg-rose-500/10 border-rose-500/20 text-rose-300'
: 'bg-violet-500/10 border-violet-500/20 text-violet-300'
}`}>
{info.file_search.status === 'searching' && <RefreshCw className="w-2.5 h-2.5 animate-spin" />}
{info.file_search.status === 'done'
? `${(info.file_search.results || []).length} found`
: info.file_search.status}
</span>
</div>
{info.file_search.error && (
<p className="text-[10px] text-rose-400 font-mono">{info.file_search.error}</p>
)}
{info.file_search.status === 'done' && (info.file_search.results || []).length > 0 && (
<div className="max-h-48 overflow-y-auto space-y-1 mt-1">
{info.file_search.results.map((r) => (
<div key={r.path} className="flex items-center justify-between gap-2 px-2 py-1.5 rounded-lg bg-slate-900/60 border border-white/5">
<div className="min-w-0">
<p className="text-[11px] font-mono font-bold text-slate-200 truncate">{r.name}</p>
<p className="text-[9px] text-slate-500 font-mono">
{r.size_mb !== undefined ? `${r.size_mb} MB` : ''}{r.modified ? ` · ${r.modified}` : ''}
</p>
</div>
<button
type="button"
onClick={() => handleRequestFile(clientId, r.name)}
className="shrink-0 px-3 py-1 rounded-md text-[10px] font-bold uppercase tracking-wider text-violet-300 bg-violet-500/10 border border-violet-500/20 hover:bg-violet-500/20 transition-all flex items-center gap-1"
>
<ArrowRight className="w-3 h-3" /> Fetch
</button>
</div>
))}
</div>
)}
</div>
)}
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"

View File

@@ -6,7 +6,7 @@ export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 6173,
port: 4173,
allowedHosts: ["rmm.seekright.com"]
},
})