bugfix on service discovery, and rework pf the dashboard
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { RulesEditor } from './components/RulesEditor';
|
||||
import ServiceNav from './components/ServiceNav';
|
||||
|
||||
interface Stats {
|
||||
fileCount: number;
|
||||
@@ -37,6 +38,7 @@ function App() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [showRules, setShowRules] = useState(false);
|
||||
const [showSetup, setShowSetup] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
@@ -97,6 +99,7 @@ function App() {
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<ServiceNav />
|
||||
<header className="header">
|
||||
<div>
|
||||
<h1>meta-fuse</h1>
|
||||
@@ -110,6 +113,32 @@ function App() {
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
<section className="description">
|
||||
<h2>What is meta-fuse?</h2>
|
||||
<p>
|
||||
<strong>meta-fuse</strong> is a virtual filesystem that presents your media files
|
||||
in an organized structure based on metadata. It reads file information from the
|
||||
KV leader (via Redis) and creates a virtual view of your library without duplicating any files.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Access files organized by title, year, series, etc.</li>
|
||||
<li>Mount via FUSE (native) or WebDAV (network drive)</li>
|
||||
<li>No file duplication - virtual paths point to real files</li>
|
||||
<li>Automatically updates when new files are processed</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="quick-links">
|
||||
<a href="/webdav" className="quick-link-card">
|
||||
<span className="quick-link-icon">📁</span>
|
||||
<div className="quick-link-content">
|
||||
<h3>WebDAV Files</h3>
|
||||
<p>Browse and access your organized media library. Mount as a network drive on any device.</p>
|
||||
<span className="quick-link-url">/webdav</span>
|
||||
</div>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<div className="section">
|
||||
<h2 className="section-title">VFS Control</h2>
|
||||
|
||||
@@ -222,6 +251,47 @@ function App() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="setup-section">
|
||||
<div
|
||||
className="setup-header"
|
||||
onClick={() => setShowSetup(!showSetup)}
|
||||
>
|
||||
<h3>Setup & Configuration</h3>
|
||||
<span className={`toggle-icon ${showSetup ? 'open' : ''}`}>▼</span>
|
||||
</div>
|
||||
{showSetup && (
|
||||
<div className="setup-content">
|
||||
<div className="info-box">
|
||||
<h4>FUSE Mount (Docker)</h4>
|
||||
<p>Mount the virtual filesystem directly from the container. Add this volume to your docker-compose.yml to access the organized files on your host:</p>
|
||||
<div className="command-box">
|
||||
<code>volumes:<br /> - /path/on/host:/mnt/virtual:rw,shared</code>
|
||||
</div>
|
||||
<p className="info-note">The virtual filesystem is available inside the container at <code>/mnt/virtual</code></p>
|
||||
</div>
|
||||
|
||||
<div className="info-box">
|
||||
<h4>WebDAV Network Drive</h4>
|
||||
<p>Mount the virtual filesystem as a network drive on any device:</p>
|
||||
<div className="command-box">
|
||||
<strong>Windows:</strong> <code>net use Z: http://<host>/webdav</code>
|
||||
</div>
|
||||
<div className="command-box">
|
||||
<strong>macOS:</strong> <code>Finder → Go → Connect to Server → http://<host>/webdav</code>
|
||||
</div>
|
||||
<div className="command-box">
|
||||
<strong>Linux:</strong> <code>sudo mount -t davfs http://<host>/webdav /mnt/vfs</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<footer className="app-footer">
|
||||
<p>Part of the <strong>MetaMesh</strong> project</p>
|
||||
<p>Discovers KV leader via shared volume and connects to Redis automatically</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface ServiceInfo {
|
||||
name: string;
|
||||
url: string;
|
||||
api: string;
|
||||
status: string;
|
||||
capabilities: string[];
|
||||
version: string;
|
||||
role?: string; // "leader", "follower", or undefined (for non-meta-core services)
|
||||
}
|
||||
|
||||
interface ServicesResponse {
|
||||
services: ServiceInfo[];
|
||||
current: string;
|
||||
}
|
||||
|
||||
const serviceIcons: Record<string, string> = {
|
||||
'meta-sort': '📁',
|
||||
'meta-fuse': '🗂️',
|
||||
'meta-stremio': '🎬',
|
||||
'meta-orbit': '🌐',
|
||||
'meta-core': '⚙️',
|
||||
'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-fuse';
|
||||
|
||||
useEffect(() => {
|
||||
const fetchServices = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/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;
|
||||
|
||||
// Filter out follower meta-core instances (only show leader)
|
||||
const filteredServices = services.filter(
|
||||
s => s.name !== 'meta-core' || s.role === 'leader'
|
||||
);
|
||||
const sortedServices = [...filteredServices].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 {
|
||||
background: var(--bg-secondary, #12121a);
|
||||
border-radius: 12px;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
border: 1px solid var(--border-color, #2a2a3a);
|
||||
}
|
||||
|
||||
.services-nav-label {
|
||||
color: var(--text-secondary, #a0a0a0);
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.services-nav-items {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.service-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
background: var(--bg-tertiary, #1a1a2a);
|
||||
border: 1px solid var(--border-color, #2a2a3a);
|
||||
}
|
||||
|
||||
.service-link:hover {
|
||||
background: var(--border-color, #2a2a3a);
|
||||
border-color: rgba(78, 205, 196, 0.5);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.service-link .service-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.service-link .service-status {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-primary, #4ecdc4);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.services-nav {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServiceNav;
|
||||
@@ -826,3 +826,217 @@ a:hover {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Description Section */
|
||||
.description {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.description h2 {
|
||||
color: var(--accent-primary);
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.description p {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.8;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.description p strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.description ul {
|
||||
margin-left: 1.5rem;
|
||||
line-height: 2;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Quick Links Section */
|
||||
.quick-links {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.quick-link-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.quick-link-card:hover {
|
||||
border-color: var(--accent-primary);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 20px rgba(78, 205, 196, 0.15);
|
||||
}
|
||||
|
||||
.quick-link-icon {
|
||||
font-size: 2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quick-link-content h3 {
|
||||
color: var(--text-primary);
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.quick-link-content p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.quick-link-url {
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
color: var(--accent-primary);
|
||||
background: rgba(78, 205, 196, 0.1);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Setup Section */
|
||||
.setup-section {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.setup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
transition: all 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.setup-header:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: rgba(78, 205, 196, 0.3);
|
||||
}
|
||||
|
||||
.setup-header h3 {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.toggle-icon.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.setup-content {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.info-box h4 {
|
||||
color: var(--accent-primary);
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-box .info-note {
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.info-box code {
|
||||
background: var(--bg-primary);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.command-box {
|
||||
background: var(--bg-primary);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.command-box:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.command-box strong {
|
||||
color: var(--accent-primary);
|
||||
display: inline-block;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.command-box code {
|
||||
color: var(--text-primary);
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.app-footer {
|
||||
text-align: center;
|
||||
padding-top: 2rem;
|
||||
margin-top: 2rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.app-footer p {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.app-footer p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.app-footer strong {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user