move feature from meta-sort to meta-core
This commit is contained in:
+1
File diff suppressed because one or more lines are too long
+67
File diff suppressed because one or more lines are too long
Vendored
+30
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>meta-core Dashboard</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
min-height: 100vh;
|
||||
}
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/index-ycUtrPCn.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BGbUc1fg.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>meta-core Dashboard</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
min-height: 100vh;
|
||||
}
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1779
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@meta-core/dashboard",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"iso-639-3": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.38",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"typescript": "^5.3.2",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:9000',
|
||||
'/health': 'http://localhost:9000',
|
||||
'/meta': 'http://localhost:9000',
|
||||
'/webdav': 'http://localhost:80',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user