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:
339
package-lock.json
generated
339
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
156
src/App.jsx
156
src/App.jsx
@@ -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"
|
||||
|
||||
@@ -6,7 +6,7 @@ export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 6173,
|
||||
port: 4173,
|
||||
allowedHosts: ["rmm.seekright.com"]
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user