move feature from meta-sort to meta-core

This commit is contained in:
Pierre
2026-01-25 22:01:25 +01:00
parent 768942d28b
commit 7d9d502912
69 changed files with 12831 additions and 13 deletions
+89
View File
@@ -0,0 +1,89 @@
import { BrowserRouter, Routes, Route, NavLink } from 'react-router-dom';
import Dashboard from './pages/Dashboard';
import Mounts from './pages/Mounts';
import Files from './pages/Files';
import ServiceNav from './components/ServiceNav';
const navStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: '1rem',
padding: '0.75rem 2rem',
background: '#16213e',
borderBottom: '1px solid #0f3460',
flexShrink: 0,
};
const linkStyle: React.CSSProperties = {
color: '#888',
textDecoration: 'none',
padding: '0.5rem 1rem',
borderRadius: '4px',
transition: 'all 0.2s',
};
const activeLinkStyle: React.CSSProperties = {
...linkStyle,
color: '#fff',
background: '#0f3460',
};
const contentStyle: React.CSSProperties = {
flex: 1,
overflow: 'hidden',
minHeight: 0,
};
// Editor page with iframe to standalone editor
function EditorPage() {
return (
<iframe
src="/editor/"
style={{ width: '100%', height: '100%', border: 'none', display: 'block' }}
title="Metadata Editor"
/>
);
}
export default function App() {
return (
<BrowserRouter>
<nav style={navStyle}>
<NavLink
to="/"
style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}
>
Dashboard
</NavLink>
<NavLink
to="/mounts"
style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}
>
Mounts
</NavLink>
<NavLink
to="/files"
style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}
>
Files
</NavLink>
<NavLink
to="/editor"
style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}
>
Editor
</NavLink>
<div style={{ flex: 1 }} />
<ServiceNav />
</nav>
<div style={contentStyle}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/mounts" element={<Mounts />} />
<Route path="/files" element={<Files />} />
<Route path="/editor" element={<EditorPage />} />
</Routes>
</div>
</BrowserRouter>
);
}
+157
View File
@@ -0,0 +1,157 @@
import { useState, useEffect } from 'react';
interface ServiceInfo {
name: string;
url: string;
api: string;
status: string;
capabilities: string[];
version: string;
}
interface ServicesResponse {
services: ServiceInfo[];
current: string;
}
const serviceIcons: Record<string, string> = {
'meta-core': '🗄️',
'meta-sort': '📁',
'meta-fuse': '🗂️',
'meta-stremio': '🎬',
'meta-orbit': '🌐',
'default': '📦'
};
function formatServiceName(name: string): string {
return name.split('-').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ');
}
function ServiceNav() {
const [services, setServices] = useState<ServiceInfo[]>([]);
const [loading, setLoading] = useState(true);
const currentService = 'meta-core';
useEffect(() => {
const fetchServices = async () => {
try {
const response = await fetch('/services');
if (response.ok) {
const data: ServicesResponse = await response.json();
setServices(data.services || []);
}
} catch (error) {
console.error('Failed to fetch services:', error);
} finally {
setLoading(false);
}
};
fetchServices();
const interval = setInterval(fetchServices, 30000);
return () => clearInterval(interval);
}, []);
if (loading) return null;
const sortedServices = [...services].sort((a, b) => a.name.localeCompare(b.name));
if (sortedServices.length === 0) return null;
return (
<nav className="services-nav">
<span className="services-nav-label">Services:</span>
<div className="services-nav-items">
{sortedServices.map(service => {
const icon = serviceIcons[service.name] || serviceIcons.default;
const isActive = service.name === currentService;
return (
<a
key={service.name}
href={isActive ? '#' : service.url}
className={`service-link${isActive ? ' active' : ''}`}
onClick={(e) => {
e.preventDefault();
if (!isActive) {
window.location.href = service.url;
}
}}
>
<span className="service-icon">{icon}</span>
<span>{formatServiceName(service.name)}</span>
<span className="service-status"></span>
</a>
);
})}
</div>
<style>{`
.services-nav {
display: flex;
align-items: center;
gap: 0.5rem;
}
.services-nav-label {
color: #888;
font-size: 0.8rem;
white-space: nowrap;
}
.services-nav-items {
display: flex;
gap: 0.35rem;
}
.service-link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.6rem;
border-radius: 6px;
text-decoration: none;
font-size: 0.8rem;
font-weight: 500;
transition: all 0.2s;
color: #ccc;
background: #0f3460;
border: 1px solid #1a4a7a;
}
.service-link:hover {
background: #1a4a7a;
border-color: rgba(78, 205, 196, 0.5);
text-decoration: none;
color: #fff;
}
.service-link.active {
background: linear-gradient(135deg, rgba(78, 205, 196, 0.2), rgba(68, 160, 141, 0.2));
border-color: rgba(78, 205, 196, 0.5);
color: #fff;
}
.service-link .service-icon {
font-size: 0.9rem;
}
.service-link .service-status {
width: 5px;
height: 5px;
border-radius: 50%;
background: #4ecdc4;
}
@media (max-width: 800px) {
.services-nav-label {
display: none;
}
}
`}</style>
</nav>
);
}
export default ServiceNav;
+33
View File
@@ -0,0 +1,33 @@
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #16213e;
--bg-tertiary: #1a1a2e;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--accent-primary: #4ecdc4;
--border-color: #0f3460;
}
*, *::before, *::after {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
}
#root {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+170
View File
@@ -0,0 +1,170 @@
import { useEffect, useState } from 'react';
interface HealthStatus {
status: string;
role: string;
redis: boolean;
timestamp: string;
}
interface ScanStatus {
status: string;
scanning: boolean;
lastScan: number;
fileCount: number;
}
interface ServiceInfo {
name: string;
api: string;
capabilities: string[];
timestamp: number;
}
const cardStyle: React.CSSProperties = {
background: '#16213e',
borderRadius: '8px',
padding: '1.5rem',
marginBottom: '1rem',
};
const gridStyle: React.CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '1rem',
};
const statStyle: React.CSSProperties = {
fontSize: '2rem',
fontWeight: 'bold',
color: '#4ade80',
};
export default function Dashboard() {
const [health, setHealth] = useState<HealthStatus | null>(null);
const [scanStatus, setScanStatus] = useState<ScanStatus | null>(null);
const [services, setServices] = useState<ServiceInfo[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
// Fetch health
const healthRes = await fetch('/health');
if (healthRes.ok) {
setHealth(await healthRes.json());
}
// Fetch scan status
const scanRes = await fetch('/api/scan/status');
if (scanRes.ok) {
setScanStatus(await scanRes.json());
}
// Fetch services
const servicesRes = await fetch('/services');
if (servicesRes.ok) {
const data = await servicesRes.json();
setServices(data.services || []);
}
setError(null);
} catch (err) {
setError(String(err));
}
};
fetchData();
const interval = setInterval(fetchData, 5000);
return () => clearInterval(interval);
}, []);
const handleTriggerScan = async () => {
try {
await fetch('/api/scan/trigger', { method: 'POST' });
} catch (err) {
console.error('Failed to trigger scan:', err);
}
};
return (
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
<h1 style={{ marginBottom: '2rem' }}>meta-core Dashboard</h1>
{error && (
<div style={{ ...cardStyle, background: '#4a1a1a', color: '#f87171' }}>
Error: {error}
</div>
)}
<div style={gridStyle}>
{/* Health Card */}
<div style={cardStyle}>
<h3 style={{ marginBottom: '1rem', color: '#888' }}>Service Health</h3>
{health ? (
<>
<div style={statStyle}>
{health.status === 'ok' ? 'Healthy' : health.status}
</div>
<p>Role: <strong>{health.role}</strong></p>
<p>Redis: {health.redis ? 'Connected' : 'Disconnected'}</p>
</>
) : (
<p>Loading...</p>
)}
</div>
{/* Scan Status Card */}
<div style={cardStyle}>
<h3 style={{ marginBottom: '1rem', color: '#888' }}>File Watcher</h3>
{scanStatus ? (
<>
<div style={statStyle}>{scanStatus.fileCount}</div>
<p>Files discovered</p>
<p>Status: {scanStatus.scanning ? 'Scanning...' : scanStatus.status}</p>
{scanStatus.lastScan > 0 && (
<p>Last scan: {new Date(scanStatus.lastScan).toLocaleString()}</p>
)}
<button
onClick={handleTriggerScan}
style={{
marginTop: '1rem',
padding: '0.5rem 1rem',
background: '#0f3460',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Trigger Scan
</button>
</>
) : (
<p>Loading...</p>
)}
</div>
{/* Services Card */}
<div style={cardStyle}>
<h3 style={{ marginBottom: '1rem', color: '#888' }}>Connected Services</h3>
<div style={statStyle}>{services.length}</div>
<p>Services registered</p>
{services.length > 0 && (
<ul style={{ marginTop: '1rem', paddingLeft: '1.5rem' }}>
{services.map((svc) => (
<li key={svc.name} style={{ marginBottom: '0.5rem' }}>
<strong>{svc.name}</strong>
<br />
<span style={{ color: '#888', fontSize: '0.9rem' }}>
{svc.capabilities?.join(', ') || 'No capabilities'}
</span>
</li>
))}
</ul>
)}
</div>
</div>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { useEffect, useState } from 'react';
interface FileEvent {
type: 'add' | 'change' | 'delete' | 'rename';
path: string;
timestamp: number;
}
const cardStyle: React.CSSProperties = {
background: '#16213e',
borderRadius: '8px',
padding: '1rem',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
minHeight: 0,
};
export default function Files() {
const [recentEvents, setRecentEvents] = useState<FileEvent[]>([]);
const fetchEvents = async () => {
try {
const res = await fetch('/api/events/poll?limit=20');
if (res.ok) {
const data = await res.json();
setRecentEvents(data.events || []);
}
} catch (err) {
console.error('Failed to fetch events:', err);
}
};
useEffect(() => {
fetchEvents();
const interval = setInterval(fetchEvents, 5000);
return () => clearInterval(interval);
}, []);
const formatTime = (timestamp: number) => {
return new Date(timestamp).toLocaleString();
};
return (
<div style={{
padding: '1rem',
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}>
<h1 style={{ margin: '0 0 1rem 0', flexShrink: 0 }}>File Browser</h1>
<div style={{
display: 'grid',
gridTemplateColumns: '2fr 1fr',
gap: '1rem',
flex: 1,
minHeight: 0
}}>
{/* WebDAV Browser Iframe */}
<div style={cardStyle}>
<iframe
src="/webdav-browser/"
style={{
width: '100%',
flex: 1,
border: 'none',
borderRadius: '4px',
background: '#fff',
display: 'block',
minHeight: 0
}}
title="File Browser"
/>
</div>
{/* Recent Events */}
<div style={cardStyle}>
<h3 style={{ margin: '0 0 0.75rem 0', flexShrink: 0 }}>Recent File Events</h3>
{recentEvents.length === 0 ? (
<p style={{ color: '#888', margin: 0 }}>No recent events</p>
) : (
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
{recentEvents.map((event, i) => (
<div
key={i}
style={{
padding: '0.5rem',
borderBottom: '1px solid #0f3460',
fontSize: '0.9rem',
}}
>
<span
style={{
display: 'inline-block',
padding: '0.1rem 0.3rem',
borderRadius: '3px',
fontSize: '0.75rem',
marginRight: '0.5rem',
background:
event.type === 'add'
? '#166534'
: event.type === 'delete'
? '#7f1d1d'
: '#854d0e',
}}
>
{event.type}
</span>
<span style={{ color: '#888' }}>{event.path}</span>
<br />
<span style={{ color: '#555', fontSize: '0.8rem' }}>
{formatTime(event.timestamp)}
</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
+328
View File
@@ -0,0 +1,328 @@
import { useEffect, useState } from 'react';
interface Mount {
id: string;
name: string;
type: 'nfs' | 'smb' | 'rclone';
enabled: boolean;
desiredMounted: boolean;
mountPath: string;
mounted: boolean;
error?: string;
lastChecked: number;
}
interface RcloneRemote {
name: string;
type: string;
}
const cardStyle: React.CSSProperties = {
background: '#16213e',
borderRadius: '8px',
padding: '1.5rem',
marginBottom: '1rem',
};
const buttonStyle: React.CSSProperties = {
padding: '0.5rem 1rem',
background: '#0f3460',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
marginRight: '0.5rem',
};
const dangerButtonStyle: React.CSSProperties = {
...buttonStyle,
background: '#7f1d1d',
};
export default function Mounts() {
const [mounts, setMounts] = useState<Mount[]>([]);
const [remotes, setRemotes] = useState<RcloneRemote[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [formData, setFormData] = useState({
name: '',
type: 'nfs' as 'nfs' | 'smb' | 'rclone',
nfsServer: '',
nfsPath: '',
smbServer: '',
smbShare: '',
smbUsername: '',
smbPassword: '',
rcloneRemote: '',
rclonePath: '',
});
const fetchMounts = async () => {
try {
const res = await fetch('/api/mounts');
if (res.ok) {
const data = await res.json();
setMounts(data.mounts || []);
}
} catch (err) {
console.error('Failed to fetch mounts:', err);
} finally {
setLoading(false);
}
};
const fetchRemotes = async () => {
try {
const res = await fetch('/api/mounts/rclone/remotes');
if (res.ok) {
const data = await res.json();
setRemotes(data.remotes || []);
}
} catch (err) {
console.error('Failed to fetch remotes:', err);
}
};
useEffect(() => {
fetchMounts();
fetchRemotes();
const interval = setInterval(fetchMounts, 5000);
return () => clearInterval(interval);
}, []);
const handleMount = async (id: string) => {
await fetch(`/api/mounts/${id}/mount`, { method: 'POST' });
fetchMounts();
};
const handleUnmount = async (id: string) => {
await fetch(`/api/mounts/${id}/unmount`, { method: 'POST' });
fetchMounts();
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this mount?')) return;
await fetch(`/api/mounts/${id}`, { method: 'DELETE' });
fetchMounts();
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
const body: Record<string, unknown> = {
name: formData.name,
type: formData.type,
};
if (formData.type === 'nfs') {
body.nfsServer = formData.nfsServer;
body.nfsPath = formData.nfsPath;
} else if (formData.type === 'smb') {
body.smbServer = formData.smbServer;
body.smbShare = formData.smbShare;
if (formData.smbUsername) body.smbUsername = formData.smbUsername;
if (formData.smbPassword) body.smbPassword = formData.smbPassword;
} else if (formData.type === 'rclone') {
body.rcloneRemote = formData.rcloneRemote;
body.rclonePath = formData.rclonePath;
}
await fetch('/api/mounts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
setShowForm(false);
setFormData({
name: '',
type: 'nfs',
nfsServer: '',
nfsPath: '',
smbServer: '',
smbShare: '',
smbUsername: '',
smbPassword: '',
rcloneRemote: '',
rclonePath: '',
});
fetchMounts();
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '0.5rem',
marginBottom: '0.5rem',
background: '#1a1a2e',
border: '1px solid #0f3460',
borderRadius: '4px',
color: '#fff',
};
if (loading) return <div style={{ padding: '2rem' }}>Loading...</div>;
return (
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<h1>Mount Management</h1>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<a
href="/rclone/"
target="_blank"
rel="noopener noreferrer"
style={{ ...buttonStyle, textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}
>
rclone UI
</a>
<button style={buttonStyle} onClick={() => setShowForm(!showForm)}>
{showForm ? 'Cancel' : 'Add Mount'}
</button>
</div>
</div>
{showForm && (
<div style={cardStyle}>
<h3 style={{ marginBottom: '1rem' }}>Add New Mount</h3>
<form onSubmit={handleCreate}>
<input
style={inputStyle}
placeholder="Mount Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
<select
style={inputStyle}
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as 'nfs' | 'smb' | 'rclone' })}
>
<option value="nfs">NFS</option>
<option value="smb">SMB/CIFS</option>
<option value="rclone">rclone</option>
</select>
{formData.type === 'nfs' && (
<>
<input
style={inputStyle}
placeholder="NFS Server (e.g., 192.168.1.100)"
value={formData.nfsServer}
onChange={(e) => setFormData({ ...formData, nfsServer: e.target.value })}
required
/>
<input
style={inputStyle}
placeholder="NFS Path (e.g., /export/media)"
value={formData.nfsPath}
onChange={(e) => setFormData({ ...formData, nfsPath: e.target.value })}
required
/>
</>
)}
{formData.type === 'smb' && (
<>
<input
style={inputStyle}
placeholder="SMB Server (e.g., 192.168.1.100)"
value={formData.smbServer}
onChange={(e) => setFormData({ ...formData, smbServer: e.target.value })}
required
/>
<input
style={inputStyle}
placeholder="Share Name (e.g., media)"
value={formData.smbShare}
onChange={(e) => setFormData({ ...formData, smbShare: e.target.value })}
required
/>
<input
style={inputStyle}
placeholder="Username (optional)"
value={formData.smbUsername}
onChange={(e) => setFormData({ ...formData, smbUsername: e.target.value })}
/>
<input
style={inputStyle}
type="password"
placeholder="Password (optional)"
value={formData.smbPassword}
onChange={(e) => setFormData({ ...formData, smbPassword: e.target.value })}
/>
</>
)}
{formData.type === 'rclone' && (
<>
<select
style={inputStyle}
value={formData.rcloneRemote}
onChange={(e) => setFormData({ ...formData, rcloneRemote: e.target.value })}
required
>
<option value="">Select Remote</option>
{remotes.map((r) => (
<option key={r.name} value={r.name}>
{r.name} ({r.type})
</option>
))}
</select>
<input
style={inputStyle}
placeholder="Remote Path (e.g., /path/to/folder)"
value={formData.rclonePath}
onChange={(e) => setFormData({ ...formData, rclonePath: e.target.value })}
/>
</>
)}
<button type="submit" style={buttonStyle}>
Create Mount
</button>
</form>
</div>
)}
{mounts.length === 0 ? (
<div style={cardStyle}>
<p>No mounts configured. Click "Add Mount" to get started.</p>
</div>
) : (
mounts.map((mount) => (
<div key={mount.id} style={cardStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h3>{mount.name}</h3>
<p style={{ color: '#888' }}>
Type: {mount.type} | Path: {mount.mountPath}
</p>
<p>
Status:{' '}
<span style={{ color: mount.mounted ? '#4ade80' : '#f87171' }}>
{mount.mounted ? 'Mounted' : 'Not mounted'}
</span>
</p>
{mount.error && (
<p style={{ color: '#f87171', marginTop: '0.5rem' }}>Error: {mount.error}</p>
)}
</div>
<div>
{mount.mounted ? (
<button style={buttonStyle} onClick={() => handleUnmount(mount.id)}>
Unmount
</button>
) : (
<button style={buttonStyle} onClick={() => handleMount(mount.id)}>
Mount
</button>
)}
<button style={dangerButtonStyle} onClick={() => handleDelete(mount.id)}>
Delete
</button>
</div>
</div>
</div>
))
)}
</div>
);
}