initial commit

This commit is contained in:
Pierre
2026-01-11 17:26:49 +01:00
commit 888790fd11
240 changed files with 48691 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
import { Routes, Route } from 'react-router-dom';
import Welcome from './pages/Welcome';
import Monitor from './pages/Monitor';
import Mounts from './pages/Mounts';
import Plugins from './pages/Plugins';
import Duplicates from './pages/Duplicates';
import ServiceNav from './components/ServiceNav';
function App() {
return (
<div className="app">
<main className="main-content">
<ServiceNav />
<Routes>
<Route path="/" element={<Welcome />} />
<Route path="/monitor" element={<Monitor />} />
<Route path="/duplicates" element={<Duplicates />} />
<Route path="/mounts" element={<Mounts />} />
<Route path="/plugins" element={<Plugins />} />
</Routes>
</main>
<style>{`
.main-content {
padding: 30px;
max-width: 1400px;
margin: 0 auto;
}
`}</style>
</div>
);
}
export default App;
@@ -0,0 +1,164 @@
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-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-sort';
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;
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 {
background: var(--bg-secondary);
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);
}
.services-nav-label {
color: var(--text-secondary);
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);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
}
.service-link:hover {
background: var(--border-color);
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);
margin-left: 0.25rem;
}
@media (max-width: 600px) {
.services-nav {
flex-direction: column;
align-items: flex-start;
}
}
`}</style>
</nav>
);
}
export default ServiceNav;
+168
View File
@@ -0,0 +1,168 @@
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-tertiary: #1a1a2e;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--accent-primary: #4ecdc4;
--accent-secondary: #ff6b6b;
--accent-warning: #ffd93d;
--border-color: #2a2a3e;
--success: #4ecdc4;
--error: #ff6b6b;
--warning: #ffd93d;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
}
a {
color: var(--accent-primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.card h2 {
color: var(--accent-primary);
margin-bottom: 15px;
font-size: 1.2rem;
}
.btn {
display: inline-block;
padding: 10px 20px;
border-radius: 6px;
border: none;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
}
.btn-primary {
background: var(--accent-primary);
color: var(--bg-primary);
}
.btn-primary:hover {
background: #3db8b0;
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: var(--border-color);
}
.grid {
display: grid;
gap: 20px;
}
.grid-2 {
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
.grid-3 {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--accent-primary);
}
.stat-label {
font-size: 0.9rem;
color: var(--text-secondary);
}
.status-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.status-success {
background: rgba(78, 205, 196, 0.2);
color: var(--success);
}
.status-error {
background: rgba(255, 107, 107, 0.2);
color: var(--error);
}
.status-warning {
background: rgba(255, 217, 61, 0.2);
color: var(--warning);
}
.progress-bar {
width: 100%;
height: 8px;
background: var(--bg-tertiary);
border-radius: 4px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: var(--accent-primary);
transition: width 0.3s ease;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
color: var(--text-secondary);
font-weight: 500;
font-size: 0.9rem;
}
tr:hover {
background: var(--bg-tertiary);
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
@@ -0,0 +1,510 @@
import { useState, useEffect, useCallback } from 'react';
import { DuplicateData, DuplicateGroup } from '../types';
import { formatNumber, formatMs, getFilename } from '../utils/format';
function Duplicates() {
const [duplicateData, setDuplicateData] = useState<DuplicateData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'hash' | 'title'>('hash');
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const fetchDuplicates = useCallback(async () => {
try {
const res = await fetch('/api/duplicates');
if (res.ok) {
const data = await res.json();
setDuplicateData(data);
setError(null);
} else {
setError('Failed to fetch duplicate data');
}
} catch (err) {
console.error('Failed to fetch duplicates:', err);
setError('Failed to fetch duplicate data');
}
}, []);
const computeDuplicates = async () => {
setLoading(true);
try {
const res = await fetch('/api/duplicates/compute', { method: 'POST' });
if (res.ok) {
await fetchDuplicates();
} else {
setError('Failed to compute duplicates');
}
} catch (err) {
console.error('Failed to compute duplicates:', err);
setError('Failed to compute duplicates');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchDuplicates();
const interval = setInterval(fetchDuplicates, 30000); // Refresh every 30s
return () => clearInterval(interval);
}, [fetchDuplicates]);
const toggleGroup = (key: string) => {
setExpandedGroups(prev => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
const expandAll = () => {
const groups = activeTab === 'hash'
? duplicateData?.hashDuplicates
: duplicateData?.titleDuplicates;
if (groups) {
setExpandedGroups(new Set(groups.map(g => g.key)));
}
};
const collapseAll = () => {
setExpandedGroups(new Set());
};
const renderDuplicateGroup = (group: DuplicateGroup, type: 'hash' | 'title') => {
const isExpanded = expandedGroups.has(group.key);
const displayKey = type === 'hash'
? `${group.key.substring(0, 16)}...`
: group.key;
return (
<div key={group.key} className={`duplicate-group ${isExpanded ? 'expanded' : ''}`}>
<div
className="group-header"
onClick={() => toggleGroup(group.key)}
>
<span className="expand-icon">{isExpanded ? '▼' : '▶'}</span>
<span className="group-key" title={group.key}>{displayKey}</span>
<span className="group-count">{group.files.length} files</span>
</div>
{isExpanded && (
<div className="group-files">
{group.files.map((file, idx) => (
<div key={idx} className="file-item">
<span className="file-index">{idx + 1}.</span>
<span className="file-name" title={file}>{getFilename(file)}</span>
<span className="file-path" title={file}>{file}</span>
</div>
))}
</div>
)}
</div>
);
};
const hashDuplicates = duplicateData?.hashDuplicates || [];
const titleDuplicates = duplicateData?.titleDuplicates || [];
const stats = duplicateData?.stats;
return (
<div className="duplicates-page">
<div className="duplicates-header">
<h1>Duplicate Analysis</h1>
<div className="header-actions">
<button
className="btn btn-primary"
onClick={computeDuplicates}
disabled={loading}
>
{loading ? 'Computing...' : 'Recompute Duplicates'}
</button>
</div>
</div>
{error && (
<div className="error-banner">{error}</div>
)}
{/* Stats Cards */}
{stats && (
<div className="stats-cards">
<div className="stat-card hash">
<div className="stat-icon">#</div>
<div className="stat-content">
<div className="stat-value">{formatNumber(stats.hashGroupCount)}</div>
<div className="stat-label">Hash Duplicate Groups</div>
<div className="stat-detail">{formatNumber(stats.hashFileCount)} total files</div>
</div>
</div>
<div className="stat-card title">
<div className="stat-icon">T</div>
<div className="stat-content">
<div className="stat-value">{formatNumber(stats.titleGroupCount)}</div>
<div className="stat-label">Title Duplicate Groups</div>
<div className="stat-detail">{formatNumber(stats.titleFileCount)} total files</div>
</div>
</div>
{duplicateData?.computedAt && (
<div className="stat-card info">
<div className="stat-icon">i</div>
<div className="stat-content">
<div className="stat-value">{formatMs(duplicateData.computationTimeMs)}</div>
<div className="stat-label">Computation Time</div>
<div className="stat-detail">
{new Date(duplicateData.computedAt).toLocaleString()}
</div>
</div>
</div>
)}
</div>
)}
{/* Tabs */}
<div className="card duplicates-card">
<div className="tabs-header">
<div className="tabs">
<button
className={`tab ${activeTab === 'hash' ? 'active' : ''}`}
onClick={() => setActiveTab('hash')}
>
Hash Duplicates ({hashDuplicates.length})
</button>
<button
className={`tab ${activeTab === 'title' ? 'active' : ''}`}
onClick={() => setActiveTab('title')}
>
Title Duplicates ({titleDuplicates.length})
</button>
</div>
<div className="tab-actions">
<button className="btn btn-small btn-secondary" onClick={expandAll}>
Expand All
</button>
<button className="btn btn-small btn-secondary" onClick={collapseAll}>
Collapse All
</button>
</div>
</div>
<div className="tab-content">
{activeTab === 'hash' && (
<div className="duplicates-list">
{hashDuplicates.length === 0 ? (
<div className="empty-state">
<p>No hash duplicates found</p>
<p className="hint">Files with identical content (same SHA-256 hash)</p>
</div>
) : (
<>
<p className="list-description">
Files with identical content (exact byte-for-byte matches)
</p>
{hashDuplicates.map(group => renderDuplicateGroup(group, 'hash'))}
</>
)}
</div>
)}
{activeTab === 'title' && (
<div className="duplicates-list">
{titleDuplicates.length === 0 ? (
<div className="empty-state">
<p>No title duplicates found</p>
<p className="hint">Files with similar parsed titles</p>
</div>
) : (
<>
<p className="list-description">
Files with matching parsed titles (may be different quality/releases)
</p>
{titleDuplicates.map(group => renderDuplicateGroup(group, 'title'))}
</>
)}
</div>
)}
</div>
</div>
<style>{`
.duplicates-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.duplicates-header h1 {
font-size: 1.8rem;
}
.error-banner {
background: rgba(255, 107, 107, 0.1);
border: 1px solid var(--error);
color: var(--error);
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 20px;
}
/* Stats Cards */
.stats-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--bg-secondary);
border-radius: 12px;
padding: 20px;
display: flex;
gap: 16px;
align-items: center;
border: 1px solid var(--border-color);
}
.stat-card.hash {
border-left: 4px solid #a78bfa;
}
.stat-card.title {
border-left: 4px solid #fbbf24;
}
.stat-card.info {
border-left: 4px solid #60a5fa;
}
.stat-icon {
width: 48px;
height: 48px;
background: var(--bg-tertiary);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
font-weight: bold;
color: var(--text-secondary);
}
.stat-card.hash .stat-icon {
color: #a78bfa;
}
.stat-card.title .stat-icon {
color: #fbbf24;
}
.stat-card.info .stat-icon {
color: #60a5fa;
}
.stat-content {
flex: 1;
}
.stat-value {
font-size: 1.8rem;
font-weight: 700;
color: var(--text-primary);
}
.stat-label {
color: var(--text-secondary);
font-size: 0.9rem;
}
.stat-detail {
color: var(--text-secondary);
font-size: 0.8rem;
margin-top: 4px;
opacity: 0.7;
}
/* Tabs */
.duplicates-card {
min-height: 400px;
}
.tabs-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
border-bottom: 1px solid var(--border-color);
padding-bottom: 12px;
}
.tabs {
display: flex;
gap: 8px;
}
.tab {
padding: 10px 20px;
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 1rem;
cursor: pointer;
border-radius: 8px 8px 0 0;
transition: all 0.2s;
}
.tab:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
.tab.active {
color: var(--accent-primary);
background: var(--bg-tertiary);
font-weight: 600;
}
.tab-actions {
display: flex;
gap: 8px;
}
.btn-small {
padding: 6px 12px;
font-size: 0.85rem;
}
/* Duplicates List */
.list-description {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 16px;
}
.duplicates-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.duplicate-group {
background: var(--bg-tertiary);
border-radius: 8px;
overflow: hidden;
}
.group-header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
transition: background 0.2s;
}
.group-header:hover {
background: var(--bg-secondary);
}
.expand-icon {
color: var(--text-secondary);
font-size: 0.8rem;
width: 16px;
}
.group-key {
flex: 1;
font-family: monospace;
font-size: 0.9rem;
color: var(--accent-primary);
}
.group-count {
background: var(--bg-secondary);
padding: 4px 10px;
border-radius: 12px;
font-size: 0.8rem;
color: var(--text-secondary);
}
.group-files {
background: var(--bg-secondary);
padding: 8px 16px;
border-top: 1px solid var(--border-color);
}
.file-item {
display: grid;
grid-template-columns: 30px 200px 1fr;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
font-size: 0.85rem;
}
.file-item:last-child {
border-bottom: none;
}
.file-index {
color: var(--text-secondary);
text-align: right;
}
.file-name {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-path {
color: var(--text-secondary);
font-family: monospace;
font-size: 0.8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.empty-state p {
margin: 0;
}
.empty-state .hint {
font-size: 0.9rem;
margin-top: 8px;
opacity: 0.7;
}
@media (max-width: 800px) {
.tabs-header {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.file-item {
grid-template-columns: 30px 1fr;
}
.file-path {
grid-column: 1 / -1;
padding-left: 42px;
}
}
`}</style>
</div>
);
}
export default Duplicates;
+980
View File
@@ -0,0 +1,980 @@
import { useState, useEffect, useCallback } from 'react';
import {
ProcessingStatus,
Metrics,
RedisStats,
QueueItem,
HashTimingStats
} from '../types';
import {
formatMs,
formatNumber,
formatUptime,
formatBytes,
getFilename
} from '../utils/format';
function Monitor() {
const [status, setStatus] = useState<ProcessingStatus | null>(null);
const [metrics, setMetrics] = useState<Metrics | null>(null);
const [redisStats, setRedisStats] = useState<RedisStats | null>(null);
const [queue, setQueue] = useState<QueueItem[]>([]);
const fetchData = useCallback(async () => {
try {
const [statusRes, metricsRes, queueRes, statsRes] = await Promise.all([
fetch('/api/processing/status'),
fetch('/api/metrics'),
fetch('/api/processing/queue'),
fetch('/api/stats')
]);
if (statusRes.ok) setStatus(await statusRes.json());
if (metricsRes.ok) setMetrics(await metricsRes.json());
if (queueRes.ok) {
const queueData = await queueRes.json();
setQueue(queueData.items || []);
}
if (statsRes.ok) setRedisStats(await statsRes.json());
} catch (err) {
console.error('Failed to fetch data:', err);
}
}, []);
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 2000);
return () => clearInterval(interval);
}, [fetchData]);
const triggerScan = async () => {
try {
await fetch('/api/scan/trigger', { method: 'POST' });
fetchData();
} catch (err) {
console.error('Failed to trigger scan:', err);
}
};
// Calculate progress for 5-stage pipeline
const getProgress = () => {
if (!status) return { pending: 0, preProcess: 0, fast: 0, background: 0, done: 0, total: 0 };
const computed = status.computed;
const preProcessCount = computed?.preProcessRunning || 0;
const fastCount = (computed?.fastQueueRunning || 0) + (computed?.fastQueuePending || 0);
const backgroundCount = (computed?.backgroundQueueRunning || 0) + (computed?.backgroundQueuePending || 0);
// Total = pending + all processing stages + done
const total = status.totalPending + preProcessCount + fastCount + backgroundCount + status.totalDone;
if (total === 0) return { pending: 0, preProcess: 0, fast: 0, background: 0, done: 100, total: 0 };
return {
pending: (status.totalPending / total) * 100,
preProcess: (preProcessCount / total) * 100,
fast: (fastCount / total) * 100,
background: (backgroundCount / total) * 100,
done: (status.totalDone / total) * 100,
total
};
};
// Get queue statistics for display
// StreamingPipeline stages: validation → lightProcessing → hashProcessing
// UI mapping: Validation → Fast Queue → Background Queue
const getQueueStats = () => {
const computed = status?.computed;
const fastQueueConcurrency = status?.fastQueueConcurrency || 16;
const backgroundQueueConcurrency = status?.backgroundQueueConcurrency || 8;
return {
// Stage 1: Validation (quick extension checks)
// Concurrency: 2x fast queue workers
preProcess: {
running: computed?.preProcessRunning || 0,
pending: computed?.preProcessPending || 0,
max: fastQueueConcurrency * 2,
paused: computed?.preProcessPaused || false
},
// Stage 2: Fast Queue (midhash256 + FFmpeg + plugins)
// Concurrency: fastQueueConcurrency workers
fast: {
running: computed?.fastQueueRunning || 0,
pending: computed?.fastQueuePending || 0,
max: fastQueueConcurrency,
paused: computed?.fastQueuePaused || false
},
// Stage 3: Background Queue (SHA-256 full file hash)
// Concurrency: backgroundQueueConcurrency workers
background: {
running: computed?.backgroundQueueRunning || 0,
pending: computed?.backgroundQueuePending || 0,
max: backgroundQueueConcurrency,
paused: computed?.backgroundQueuePaused || false
}
};
};
const progress = getProgress();
const queueStats = getQueueStats();
return (
<div className="monitor-page">
<div className="monitor-header">
<h1>Processing Monitor</h1>
<button className="btn btn-primary" onClick={triggerScan}>
Trigger Scan
</button>
</div>
{/* Pipeline Progress Bar - 5 Stage */}
{status && (
<div className="card pipeline-section">
<h2>Processing Pipeline</h2>
<div className="pipeline-bar-container">
<div className="pipeline-bar">
<div className="bar-segment pending" style={{ width: `${progress.pending}%` }} />
<div className="bar-segment preprocess" style={{ width: `${progress.preProcess}%` }} />
<div className="bar-segment fast" style={{ width: `${progress.fast}%` }} />
<div className="bar-segment background" style={{ width: `${progress.background}%` }} />
<div className="bar-segment done" style={{ width: `${progress.done}%` }} />
</div>
</div>
<div className="pipeline-stats">
<div className="pipeline-stat pending">
<span className="stat-dot"></span>
<span className="stat-label">Pending</span>
<span className="stat-value">{formatNumber(status.totalPending)}</span>
</div>
<div className="pipeline-stat preprocess">
<span className="stat-dot"></span>
<span className="stat-label">Validation</span>
<span className="stat-value">
{queueStats.preProcess.running}/{queueStats.preProcess.max}
</span>
{queueStats.preProcess.paused && <span className="paused-indicator">paused</span>}
</div>
<div className="pipeline-stat fast">
<span className="stat-dot"></span>
<span className="stat-label">Fast Queue</span>
<span className="stat-value">
{queueStats.fast.running}/{queueStats.fast.max}
</span>
{queueStats.fast.paused && <span className="paused-indicator">paused</span>}
</div>
<div className="pipeline-stat background">
<span className="stat-dot"></span>
<span className="stat-label">Background Queue</span>
<span className="stat-value">
{queueStats.background.running}/{queueStats.background.max}
</span>
{queueStats.background.paused && <span className="paused-indicator">paused</span>}
</div>
<div className="pipeline-stat done">
<span className="stat-dot"></span>
<span className="stat-label">Complete</span>
<span className="stat-value">{formatNumber(status.totalDone)}</span>
</div>
</div>
{progress.total > 0 && (
<div className="pipeline-summary">
{formatNumber(progress.total)} total files |{' '}
{((status.totalDone / progress.total) * 100).toFixed(1)}% complete
</div>
)}
</div>
)}
{/* Queue Status Cards - StreamingPipeline stages */}
{status && (
<div className="queue-cards">
<div className="queue-card preprocess">
<div className="queue-card-header">
<span className="queue-icon">✓</span>
<span className="queue-name">Validation</span>
</div>
<div className="queue-card-body">
<div className="queue-stat">
<span className="queue-stat-label">Running</span>
<span className="queue-stat-value">{queueStats.preProcess.running}/{queueStats.preProcess.max}</span>
</div>
<div className="queue-stat">
<span className="queue-stat-label">Pending</span>
<span className="queue-stat-value">{queueStats.preProcess.pending}</span>
</div>
<div className="queue-progress-bar">
<div
className="queue-progress-fill"
style={{ width: `${(queueStats.preProcess.running / queueStats.preProcess.max) * 100}%` }}
/>
</div>
<div className="queue-status">
{queueStats.preProcess.paused ? (
<span className="status-paused">⏸ Paused</span>
) : queueStats.preProcess.running > 0 ? (
<span className="status-running">▶ Running</span>
) : (
<span className="status-idle">● Idle</span>
)}
</div>
</div>
<div className="queue-card-footer">
<span className="queue-description">Extension checks</span>
</div>
</div>
<div className="queue-card fast">
<div className="queue-card-header">
<span className="queue-icon">⚡</span>
<span className="queue-name">Fast Queue</span>
</div>
<div className="queue-card-body">
<div className="queue-stat">
<span className="queue-stat-label">Running</span>
<span className="queue-stat-value">{queueStats.fast.running}/{queueStats.fast.max}</span>
</div>
<div className="queue-stat">
<span className="queue-stat-label">Pending</span>
<span className="queue-stat-value">{queueStats.fast.pending}</span>
</div>
<div className="queue-progress-bar">
<div
className="queue-progress-fill"
style={{ width: `${(queueStats.fast.running / queueStats.fast.max) * 100}%` }}
/>
</div>
<div className="queue-status">
{queueStats.fast.paused ? (
<span className="status-paused">⏸ Paused</span>
) : queueStats.fast.running > 0 ? (
<span className="status-running">▶ Running</span>
) : (
<span className="status-idle">● Idle</span>
)}
</div>
</div>
<div className="queue-card-footer">
<span className="queue-description">MidHash + FFmpeg + Plugins</span>
</div>
</div>
<div className="queue-card background">
<div className="queue-card-header">
<span className="queue-icon">🔄</span>
<span className="queue-name">Background Queue</span>
</div>
<div className="queue-card-body">
<div className="queue-stat">
<span className="queue-stat-label">Running</span>
<span className="queue-stat-value">{queueStats.background.running}/{queueStats.background.max}</span>
</div>
<div className="queue-stat">
<span className="queue-stat-label">Pending</span>
<span className="queue-stat-value">{queueStats.background.pending}</span>
</div>
<div className="queue-progress-bar">
<div
className="queue-progress-fill"
style={{ width: `${(queueStats.background.running / queueStats.background.max) * 100}%` }}
/>
</div>
<div className="queue-status">
{queueStats.background.paused ? (
<span className="status-paused">⏸ Paused</span>
) : queueStats.background.running > 0 ? (
<span className="status-running">▶ Running</span>
) : (
<span className="status-idle">● Idle</span>
)}
</div>
</div>
<div className="queue-card-footer">
<span className="queue-description">SHA-256 full file hash</span>
</div>
</div>
</div>
)}
<div className="grid grid-2">
{/* Metrics and Stats */}
<div className="card">
<h2>Performance Metrics</h2>
{metrics && (
<div className="metrics-grid">
<div className="metric-box">
<h3>Processed</h3>
<div className="metric-value">{formatNumber(metrics.totalFilesProcessed)}</div>
<div className="metric-label">of {formatNumber(metrics.totalFilesDiscovered)} discovered</div>
{metrics.totalFilesFailed > 0 && (
<div className="metric-error">
{metrics.totalFilesFailed} failed
</div>
)}
</div>
<div className="metric-box">
<h3>Avg Metadata</h3>
<div className="metric-value">{formatMs(metrics.averageLightProcessingMs)}</div>
<div className="metric-label">per file</div>
</div>
<div className="metric-box">
<h3>Avg Hashing</h3>
<div className="metric-value">{formatMs(metrics.averageHashProcessingMs)}</div>
<div className="metric-label">per file</div>
</div>
<div className="metric-box throughput">
<h3>Throughput</h3>
<div className="metric-value">{(metrics.filesPerSecond * 60).toFixed(1)}</div>
<div className="metric-label">Files/min</div>
</div>
</div>
)}
<h3 className="section-title">System Stats</h3>
<div className="stats-grid">
<div className="stat-box">
<div className="stat-value">{redisStats ? formatNumber(redisStats.fileCount) : '-'}</div>
<div className="stat-label">Files in Redis</div>
</div>
<div className="stat-box">
<div className="stat-value">{redisStats?.memoryUsage || '-'}</div>
<div className="stat-label">Redis Memory</div>
</div>
<div className="stat-box">
<div className="stat-value">{metrics ? formatUptime(metrics.uptime) : '-'}</div>
<div className="stat-label">Uptime</div>
</div>
<div className="stat-box">
<div className="stat-value">
{metrics?.memoryUsage
? `${Math.round(metrics.memoryUsage.heapUsed / 1024 / 1024)} MB`
: '-'}
</div>
<div className="stat-label">Node Heap</div>
</div>
</div>
</div>
{/* Total Size Card */}
<div className="card total-size-card">
<h2>Total Size</h2>
<div className="total-size-value">
{redisStats ? formatBytes(redisStats.totalSize) : '-'}
</div>
<div className="total-size-label">Watch Folders</div>
</div>
</div>
{/* Hash Computation Performance */}
{metrics?.hashComputationTimes && Object.keys(metrics.hashComputationTimes).length > 0 && (
<div className="card">
<h2>Hash Computation Performance</h2>
<p className="card-description">Per-algorithm timing for hash computations</p>
<div className="hash-timing-grid">
{Object.entries(metrics.hashComputationTimes).map(([algorithm, stats]: [string, HashTimingStats]) => {
const displayName = algorithm
.replace('sha256', 'SHA-256')
.replace('sha1', 'SHA-1')
.replace('md5', 'MD5')
.replace('midhash256', 'MidHash');
const isQuick = stats.average < 1000;
return (
<div key={algorithm} className={`timing-card ${isQuick ? 'quick' : 'slow'}`}>
<h3>{displayName}</h3>
<div className="timing-value">{formatMs(stats.average)}</div>
<div className="timing-label">Average Time</div>
<div className="timing-details">
{formatNumber(stats.count)} computed |
min: {formatMs(stats.min)} |
max: {formatMs(stats.max)}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Active Queue */}
<div className="card">
<h2>Active Processing ({queue.length})</h2>
{queue.length > 0 ? (
<table>
<thead>
<tr>
<th>File</th>
<th>Stage</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
{queue.slice(0, 100).map((item, i) => {
// Map legacy phase to new stage names
const getStageInfo = () => {
if (item.queue) {
// New queue field
switch (item.queue) {
case 'validation': return { label: 'Validation', class: 'validation' };
case 'metadata': return { label: 'Fast Queue', class: 'metadata' };
case 'hashing': return { label: 'Background Queue', class: 'hashing' };
default: return { label: item.queue, class: 'unknown' };
}
}
// Legacy phase field
switch (item.phase) {
case 'light': return { label: 'Fast Queue', class: 'metadata' };
case 'hash': return { label: 'Background Queue', class: 'hashing' };
default: return { label: item.phase, class: 'unknown' };
}
};
const stageInfo = getStageInfo();
return (
<tr key={i}>
<td className="file-path" title={item.path}>{getFilename(item.path)}</td>
<td>
<span className={`stage-badge stage-${stageInfo.class}`}>
{stageInfo.label}
</span>
{item.plugin && (
<span className="plugin-badge">{item.plugin}</span>
)}
</td>
<td>
{item.startTime ? formatMs(Date.now() - item.startTime) : '-'}
</td>
</tr>
);
})}
</tbody>
</table>
) : (
<div className="empty-state">No files currently processing</div>
)}
</div>
{/* Watched Folders */}
{status && status.watchedFolders && (
<div className="card">
<h2>Watched Folders</h2>
<ul className="folder-list">
{(Array.isArray(status.watchedFolders)
? status.watchedFolders
: [status.watchedFolders]
).map((folder, i) => (
<li key={i}>{folder}</li>
))}
</ul>
</div>
)}
<style>{`
.monitor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.monitor-header h1 {
font-size: 1.8rem;
}
/* Pipeline Progress Bar */
.pipeline-section {
margin-bottom: 24px;
}
.pipeline-section h2 {
margin-bottom: 16px;
}
.pipeline-bar-container {
margin-bottom: 16px;
}
.pipeline-bar {
display: flex;
height: 24px;
border-radius: 12px;
overflow: hidden;
background: var(--bg-tertiary);
}
.bar-segment {
transition: width 0.3s ease;
}
.bar-segment.pending {
background: linear-gradient(90deg, #6b7280, #9ca3af);
}
.bar-segment.preprocess {
background: linear-gradient(90deg, #3b82f6, #60a5fa);
}
.bar-segment.fast {
background: linear-gradient(90deg, #f59e0b, #fbbf24);
}
.bar-segment.background {
background: linear-gradient(90deg, #8b5cf6, #a78bfa);
}
.bar-segment.done {
background: linear-gradient(90deg, #10b981, #34d399);
}
.pipeline-stats {
display: flex;
justify-content: space-around;
flex-wrap: wrap;
gap: 12px;
}
.pipeline-stat {
display: flex;
align-items: center;
gap: 8px;
}
.stat-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.pipeline-stat.pending .stat-dot { background: #9ca3af; }
.pipeline-stat.preprocess .stat-dot { background: #60a5fa; }
.pipeline-stat.fast .stat-dot { background: #fbbf24; }
.pipeline-stat.background .stat-dot { background: #a78bfa; }
.pipeline-stat.done .stat-dot { background: #34d399; }
.pipeline-stat .stat-label {
color: var(--text-secondary);
font-size: 0.9rem;
}
.pipeline-stat .stat-value {
font-weight: 700;
font-size: 1.1rem;
}
.pipeline-stat.pending .stat-value { color: #9ca3af; }
.pipeline-stat.preprocess .stat-value { color: #60a5fa; }
.pipeline-stat.fast .stat-value { color: #fbbf24; }
.pipeline-stat.background .stat-value { color: #a78bfa; }
.pipeline-stat.done .stat-value { color: #34d399; }
.paused-indicator {
font-size: 0.75rem;
color: var(--accent-warning);
background: rgba(245, 158, 11, 0.2);
padding: 2px 6px;
border-radius: 4px;
}
.pipeline-summary {
text-align: center;
margin-top: 16px;
color: var(--text-secondary);
font-size: 0.9rem;
}
/* Queue Status Cards */
.queue-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 24px;
}
@media (max-width: 900px) {
.queue-cards {
grid-template-columns: 1fr;
}
}
.queue-card {
background: var(--bg-secondary);
border-radius: 12px;
padding: 16px;
border: 1px solid var(--border-color);
}
.queue-card.preprocess {
border-top: 3px solid #60a5fa;
}
.queue-card.fast {
border-top: 3px solid #fbbf24;
}
.queue-card.background {
border-top: 3px solid #a78bfa;
}
.queue-card-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.queue-icon {
font-size: 1.2rem;
}
.queue-name {
font-weight: 600;
font-size: 1.1rem;
color: var(--text-primary);
}
.queue-card-body {
display: flex;
flex-direction: column;
gap: 12px;
}
.queue-stat {
display: flex;
justify-content: space-between;
align-items: center;
}
.queue-stat-label {
color: var(--text-secondary);
font-size: 0.9rem;
}
.queue-stat-value {
font-weight: 600;
font-size: 1.1rem;
color: var(--text-primary);
}
.queue-progress-bar {
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
}
.queue-progress-fill {
height: 100%;
transition: width 0.3s ease;
}
.queue-card.preprocess .queue-progress-fill {
background: linear-gradient(90deg, #3b82f6, #60a5fa);
}
.queue-card.fast .queue-progress-fill {
background: linear-gradient(90deg, #f59e0b, #fbbf24);
}
.queue-card.background .queue-progress-fill {
background: linear-gradient(90deg, #8b5cf6, #a78bfa);
}
.queue-status {
text-align: center;
margin-top: 4px;
}
.status-running {
color: #34d399;
font-size: 0.85rem;
font-weight: 500;
}
.status-paused {
color: #fbbf24;
font-size: 0.85rem;
font-weight: 500;
}
.status-idle {
color: var(--text-secondary);
font-size: 0.85rem;
}
.queue-card-footer {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border-color);
}
.queue-description {
font-size: 0.8rem;
color: var(--text-secondary);
}
.more-files {
text-align: center;
padding: 12px;
color: var(--text-secondary);
font-style: italic;
}
/* Total Size Card */
.total-size-card {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
}
.total-size-card h2 {
margin-bottom: 16px;
}
.total-size-value {
font-size: 2.5rem;
font-weight: 700;
color: var(--accent-primary);
margin-bottom: 8px;
}
.total-size-label {
color: var(--text-secondary);
font-size: 1rem;
}
/* Metrics Grid */
.metrics-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
@media (max-width: 900px) {
.metrics-grid {
grid-template-columns: repeat(2, 1fr);
}
.pipeline-cards {
grid-template-columns: repeat(2, 1fr);
}
}
.metric-box {
text-align: center;
padding: 15px;
background: var(--bg-tertiary);
border-radius: 8px;
}
.metric-box h3 {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 8px;
}
.metric-value {
font-size: 1.8rem;
font-weight: 700;
color: var(--accent-primary);
}
.metric-label {
color: var(--text-secondary);
font-size: 0.85rem;
}
.metric-error {
color: var(--error);
font-size: 0.8rem;
margin-top: 8px;
}
/* Stats Grid */
.section-title {
margin-top: 24px;
margin-bottom: 12px;
color: var(--text-secondary);
font-size: 0.9rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.stat-box {
background: var(--bg-tertiary);
border-radius: 8px;
padding: 12px;
text-align: center;
}
.stat-value {
font-size: 1.4rem;
font-weight: 700;
color: var(--accent-primary);
}
.stat-label {
color: var(--text-secondary);
font-size: 0.8rem;
}
/* Hash Timing */
.card-description {
color: var(--text-secondary);
margin-bottom: 16px;
font-size: 0.9rem;
}
.hash-timing-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
}
.timing-card {
background: var(--bg-tertiary);
border-radius: 8px;
padding: 16px;
text-align: center;
}
.timing-card.quick {
border: 1px solid var(--accent-primary);
}
.timing-card.slow {
border: 1px solid var(--accent-warning);
}
.timing-card h3 {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 8px;
}
.timing-value {
font-size: 1.8rem;
font-weight: 700;
color: var(--accent-primary);
}
.timing-label {
color: var(--text-secondary);
font-size: 0.85rem;
}
.timing-details {
margin-top: 12px;
font-size: 0.75rem;
color: var(--text-secondary);
}
/* Card Header with Action */
.card-header-with-action {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.card-header-with-action h2 {
margin: 0;
}
.btn-small {
padding: 6px 12px;
font-size: 0.8rem;
}
/* Empty State */
.empty-state {
text-align: center;
color: var(--text-secondary);
padding: 40px;
}
.empty-state.success {
color: var(--accent-primary);
}
.loading-icon {
font-size: 2rem;
margin-bottom: 12px;
}
.hint {
font-size: 0.8rem;
margin-top: 8px;
opacity: 0.7;
}
/* Stage Badges */
.stage-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 500;
}
.stage-validation {
background: rgba(59, 130, 246, 0.15);
color: #60a5fa;
}
.stage-metadata {
background: rgba(245, 158, 11, 0.15);
color: #fbbf24;
}
.stage-hashing {
background: rgba(139, 92, 246, 0.15);
color: #a78bfa;
}
.plugin-badge {
display: inline-block;
margin-left: 8px;
padding: 2px 8px;
border-radius: 8px;
font-size: 0.7rem;
background: var(--bg-tertiary);
color: var(--text-secondary);
}
/* Table */
.file-path {
font-family: monospace;
max-width: 400px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Folder List */
.folder-list {
list-style: none;
}
.folder-list li {
padding: 10px 15px;
background: var(--bg-tertiary);
border-radius: 6px;
margin-bottom: 8px;
font-family: monospace;
}
`}</style>
</div>
);
}
export default Monitor;
+601
View File
@@ -0,0 +1,601 @@
import { useState, useEffect } from 'react';
interface MountConfig {
id: string;
name: string;
type: 'nfs' | 'smb' | 'rclone';
enabled: boolean;
mountPath: string;
// Type-specific
nfsServer?: string;
nfsPath?: string;
smbServer?: string;
smbShare?: string;
rcloneRemote?: string;
rclonePath?: string;
}
interface MountStatus extends MountConfig {
mounted: boolean;
error?: string;
lastChecked: number;
}
interface RcloneRemote {
name: string;
type: string;
}
function Mounts() {
const [mounts, setMounts] = useState<MountStatus[]>([]);
const [remotes, setRemotes] = useState<RcloneRemote[]>([]);
const [loading, setLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false);
const [newMount, setNewMount] = useState<Partial<MountConfig>>({
type: 'rclone',
enabled: true,
});
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
setLoading(true);
const [mountsRes, remotesRes] = await Promise.all([
fetch('/api/mounts'),
fetch('/api/mounts/rclone/remotes')
]);
if (mountsRes.ok) {
const data = await mountsRes.json();
setMounts(data.mounts || []);
}
if (remotesRes.ok) {
const data = await remotesRes.json();
setRemotes(data.remotes || []);
}
} catch (err) {
console.error('Failed to fetch mounts:', err);
} finally {
setLoading(false);
}
};
const handleMount = async (id: string) => {
try {
await fetch(`/api/mounts/${id}/mount`, { method: 'POST' });
fetchData();
} catch (err) {
console.error('Failed to mount:', err);
}
};
const handleUnmount = async (id: string) => {
try {
await fetch(`/api/mounts/${id}/unmount`, { method: 'POST' });
fetchData();
} catch (err) {
console.error('Failed to unmount:', err);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to remove this mount?')) return;
try {
await fetch(`/api/mounts/${id}`, { method: 'DELETE' });
fetchData();
} catch (err) {
console.error('Failed to delete mount:', err);
}
};
const handleAddMount = async () => {
try {
const res = await fetch('/api/mounts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newMount),
});
if (res.ok) {
setShowAddModal(false);
setNewMount({ type: 'rclone', enabled: true });
fetchData();
}
} catch (err) {
console.error('Failed to add mount:', err);
}
};
const getMountIcon = (type: string) => {
switch (type) {
case 'nfs':
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/>
<line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
);
case 'smb':
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
<line x1="12" y1="11" x2="12" y2="17"/>
<line x1="9" y1="14" x2="15" y2="14"/>
</svg>
);
case 'rclone':
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/>
</svg>
);
default:
return null;
}
};
return (
<div className="mounts-page">
<div className="mounts-header">
<h1>Remote Mounts</h1>
<div className="mounts-controls">
<a href="/rclone/" target="_blank" rel="noopener noreferrer" className="btn btn-secondary">
rclone Web UI
</a>
<button className="btn btn-primary" onClick={() => setShowAddModal(true)}>
Add Mount
</button>
</div>
</div>
{remotes.length > 0 && (
<div className="card">
<h2>Available rclone Remotes</h2>
<div className="remotes-grid">
{remotes.map((remote) => (
<div key={remote.name} className="remote-item">
<span className="remote-name">{remote.name}</span>
<span className="remote-type">{remote.type}</span>
</div>
))}
</div>
</div>
)}
{loading ? (
<div className="loading">Loading mounts...</div>
) : mounts.length === 0 ? (
<div className="empty-state">
<p>No mounts configured</p>
<p className="hint">Add a mount to connect remote storage like NFS, SMB, or cloud storage via rclone</p>
</div>
) : (
<div className="mounts-grid">
{mounts.map((mount) => (
<div key={mount.id} className={`mount-card ${mount.mounted ? 'mounted' : ''}`}>
<div className="mount-header">
<div className="mount-icon">{getMountIcon(mount.type)}</div>
<div className="mount-info">
<h3>{mount.name}</h3>
<span className="mount-type">{mount.type.toUpperCase()}</span>
</div>
<div className={`mount-status ${mount.mounted ? 'status-success' : 'status-error'}`}>
{mount.mounted ? 'Mounted' : 'Unmounted'}
</div>
</div>
<div className="mount-details">
<div className="detail">
<span className="label">Mount Path</span>
<span className="value">{mount.mountPath}</span>
</div>
{mount.type === 'nfs' && (
<div className="detail">
<span className="label">Server</span>
<span className="value">{mount.nfsServer}:{mount.nfsPath}</span>
</div>
)}
{mount.type === 'smb' && (
<div className="detail">
<span className="label">Share</span>
<span className="value">//{mount.smbServer}/{mount.smbShare}</span>
</div>
)}
{mount.type === 'rclone' && (
<div className="detail">
<span className="label">Remote</span>
<span className="value">{mount.rcloneRemote}{mount.rclonePath || ''}</span>
</div>
)}
</div>
{mount.error && (
<div className="mount-error">{mount.error}</div>
)}
<div className="mount-actions">
{mount.mounted ? (
<button className="btn btn-secondary" onClick={() => handleUnmount(mount.id)}>
Unmount
</button>
) : (
<button className="btn btn-primary" onClick={() => handleMount(mount.id)}>
Mount
</button>
)}
<button className="btn btn-danger" onClick={() => handleDelete(mount.id)}>
Remove
</button>
</div>
</div>
))}
</div>
)}
{showAddModal && (
<div className="modal-overlay" onClick={() => setShowAddModal(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Add Mount</h2>
<button className="close-btn" onClick={() => setShowAddModal(false)}>×</button>
</div>
<div className="modal-content">
<div className="form-group">
<label>Name</label>
<input
type="text"
value={newMount.name || ''}
onChange={(e) => setNewMount({ ...newMount, name: e.target.value })}
placeholder="My Remote Storage"
/>
</div>
<div className="form-group">
<label>Type</label>
<select
value={newMount.type}
onChange={(e) => setNewMount({ ...newMount, type: e.target.value as any })}
>
<option value="rclone">rclone (Cloud)</option>
<option value="nfs">NFS</option>
<option value="smb">SMB/CIFS</option>
</select>
</div>
<div className="form-group">
<label>Mount Path</label>
<input
type="text"
value={newMount.mountPath || ''}
onChange={(e) => setNewMount({ ...newMount, mountPath: e.target.value })}
placeholder="/mnt/remote"
/>
</div>
{newMount.type === 'rclone' && (
<>
<div className="form-group">
<label>rclone Remote</label>
<select
value={newMount.rcloneRemote || ''}
onChange={(e) => setNewMount({ ...newMount, rcloneRemote: e.target.value })}
>
<option value="">Select remote...</option>
{remotes.map((r) => (
<option key={r.name} value={r.name}>{r.name} ({r.type})</option>
))}
</select>
</div>
<div className="form-group">
<label>Path in Remote (optional)</label>
<input
type="text"
value={newMount.rclonePath || ''}
onChange={(e) => setNewMount({ ...newMount, rclonePath: e.target.value })}
placeholder="/path/to/folder"
/>
</div>
</>
)}
{newMount.type === 'nfs' && (
<>
<div className="form-group">
<label>NFS Server</label>
<input
type="text"
value={newMount.nfsServer || ''}
onChange={(e) => setNewMount({ ...newMount, nfsServer: e.target.value })}
placeholder="192.168.1.100"
/>
</div>
<div className="form-group">
<label>NFS Path</label>
<input
type="text"
value={newMount.nfsPath || ''}
onChange={(e) => setNewMount({ ...newMount, nfsPath: e.target.value })}
placeholder="/export/media"
/>
</div>
</>
)}
{newMount.type === 'smb' && (
<>
<div className="form-group">
<label>SMB Server</label>
<input
type="text"
value={newMount.smbServer || ''}
onChange={(e) => setNewMount({ ...newMount, smbServer: e.target.value })}
placeholder="192.168.1.100"
/>
</div>
<div className="form-group">
<label>Share Name</label>
<input
type="text"
value={newMount.smbShare || ''}
onChange={(e) => setNewMount({ ...newMount, smbShare: e.target.value })}
placeholder="media"
/>
</div>
</>
)}
<div className="form-actions">
<button className="btn btn-secondary" onClick={() => setShowAddModal(false)}>
Cancel
</button>
<button className="btn btn-primary" onClick={handleAddMount}>
Add Mount
</button>
</div>
</div>
</div>
</div>
)}
<style>{`
.mounts-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.mounts-header h1 {
font-size: 1.8rem;
}
.mounts-controls {
display: flex;
gap: 15px;
}
.remotes-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.remote-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 15px;
background: var(--bg-tertiary);
border-radius: 6px;
}
.remote-name {
font-weight: 500;
}
.remote-type {
color: var(--text-secondary);
font-size: 0.85rem;
}
.loading, .empty-state {
text-align: center;
color: var(--text-secondary);
padding: 60px;
background: var(--bg-secondary);
border-radius: 8px;
}
.empty-state .hint {
margin-top: 10px;
font-size: 0.9rem;
}
.mounts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
}
.mount-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 20px;
transition: border-color 0.2s;
}
.mount-card.mounted {
border-color: var(--accent-primary);
}
.mount-header {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 15px;
}
.mount-icon {
color: var(--accent-primary);
}
.mount-info {
flex: 1;
}
.mount-info h3 {
margin-bottom: 4px;
}
.mount-type {
font-size: 0.75rem;
color: var(--text-secondary);
background: var(--bg-tertiary);
padding: 2px 8px;
border-radius: 4px;
}
.mount-status {
font-size: 0.85rem;
padding: 4px 10px;
border-radius: 12px;
}
.mount-details {
margin-bottom: 15px;
}
.mount-details .detail {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
}
.mount-details .detail:last-child {
border-bottom: none;
}
.mount-details .label {
color: var(--text-secondary);
}
.mount-details .value {
font-family: monospace;
font-size: 0.9rem;
}
.mount-error {
background: rgba(255, 107, 107, 0.1);
color: var(--error);
padding: 10px;
border-radius: 6px;
font-size: 0.9rem;
margin-bottom: 15px;
}
.mount-actions {
display: flex;
gap: 10px;
}
.btn-danger {
background: transparent;
color: var(--error);
border: 1px solid var(--error);
}
.btn-danger:hover {
background: rgba(255, 107, 107, 0.1);
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
width: 90%;
max-width: 500px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid var(--border-color);
}
.modal-header h2 {
color: var(--accent-primary);
}
.close-btn {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.5rem;
cursor: pointer;
}
.modal-content {
padding: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: var(--text-secondary);
}
.form-group input,
.form-group select {
width: 100%;
padding: 10px 15px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent-primary);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--border-color);
}
`}</style>
</div>
);
}
export default Mounts;
File diff suppressed because it is too large Load Diff
+140
View File
@@ -0,0 +1,140 @@
import { Link } from 'react-router-dom';
function Welcome() {
return (
<div className="welcome-page">
<header className="welcome-header">
<h1>meta-sort</h1>
<p>File sorting and metadata extraction service</p>
</header>
<div className="quick-links">
<div className="links-grid">
<Link to="/monitor" className="link-card">
<div className="link-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
</div>
<h3>Monitor</h3>
<p>Real-time processing dashboard</p>
</Link>
<Link to="/duplicates" className="link-card">
<div className="link-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="8" y="2" width="13" height="18" rx="2" ry="2"/>
<rect x="3" y="4" width="13" height="18" rx="2" ry="2"/>
</svg>
</div>
<h3>Duplicates</h3>
<p>Find duplicate files</p>
</Link>
<Link to="/plugins" className="link-card">
<div className="link-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
</div>
<h3>Plugins</h3>
<p>Manage metadata plugins</p>
</Link>
<Link to="/mounts" className="link-card">
<div className="link-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</div>
<h3>Mounts</h3>
<p>Manage remote storage</p>
</Link>
<a href="/editor/" className="link-card">
<div className="link-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</div>
<h3>Editor</h3>
<p>Edit file metadata</p>
</a>
</div>
</div>
<style>{`
.welcome-page {
max-width: 1000px;
margin: 0 auto;
}
.welcome-header {
text-align: center;
margin-bottom: 40px;
}
.welcome-header h1 {
font-size: 3rem;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 10px;
}
.welcome-header p {
color: var(--text-secondary);
font-size: 1.2rem;
}
.links-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.link-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 30px 20px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
text-align: center;
text-decoration: none;
transition: all 0.2s;
}
.link-card:hover {
border-color: var(--accent-primary);
transform: translateY(-2px);
text-decoration: none;
}
.link-icon {
color: var(--accent-primary);
margin-bottom: 15px;
}
.link-card h3 {
color: var(--text-primary);
margin-bottom: 8px;
}
.link-card p {
color: var(--text-secondary);
font-size: 0.9rem;
}
`}</style>
</div>
);
}
export default Welcome;
+217
View File
@@ -0,0 +1,217 @@
/**
* Shared type definitions for meta-sort-ui
*/
export type FileState = 'pending' | 'lightProcessing' | 'hashProcessing' | 'done';
export interface FileStateInfo {
filePath: string;
state: FileState;
hash?: string;
error?: string;
retryCount?: number;
discoveredAt?: number;
lightProcessingStartedAt?: number;
lightProcessingCompletedAt?: number;
hashProcessingStartedAt?: number;
hashProcessingCompletedAt?: number;
}
export interface QueueInfo {
pending: number;
size: number;
paused?: boolean;
running?: number;
isPaused?: boolean;
}
/** New task queue status for the 3-queue architecture */
export interface TaskQueueStatus {
running: number;
pending: number;
size: number;
isPaused: boolean;
}
export interface ComputedQueueStats {
// New 3-queue architecture
preProcessRunning: number;
preProcessPending: number;
preProcessPaused: boolean;
fastQueueRunning: number;
fastQueuePending: number;
fastQueueTotal: number;
fastQueuePaused: boolean;
backgroundQueueRunning: number;
backgroundQueuePending: number;
backgroundQueueTotal: number;
backgroundQueuePaused: boolean;
// Legacy fields for backwards compatibility
actualRunningLightWorkers: number;
actualRunningHashWorkers: number;
trueHashQueueSize: number;
hashQueuePaused: boolean;
lightQueuePaused: boolean;
}
export interface PipelineStats {
discovered: number;
validated: number;
lightProcessed: number;
hashProcessed: number;
queues: {
validation: QueueInfo;
light: QueueInfo;
hash: QueueInfo;
};
}
/** Queue status from the new TaskScheduler */
export interface TaskSchedulerQueueStatus {
preProcessQueue?: TaskQueueStatus;
fastQueue?: TaskQueueStatus;
backgroundQueue?: TaskQueueStatus;
}
export interface ProcessingStatus {
pending: FileStateInfo[];
lightProcessing: FileStateInfo[];
hashProcessing: FileStateInfo[];
done: FileStateInfo[];
totalPending: number;
totalLightProcessing: number;
totalHashProcessing: number;
totalDone: number;
watchedFolders: string[];
fastQueueConcurrency?: number;
backgroundQueueConcurrency?: number;
pipeline?: PipelineStats;
computed?: ComputedQueueStats;
queueStatus?: TaskSchedulerQueueStatus;
// New concurrency config
preProcessConcurrency?: number;
}
export interface HashTimingStats {
total: number;
count: number;
average: number;
min: number;
max: number;
}
export interface PluginTimingStats {
total: number;
count: number;
average: number;
min: number;
max: number;
}
export interface Metrics {
totalFilesProcessed: number;
totalFilesDiscovered: number;
totalFilesFailed: number;
averageLightProcessingMs: number;
averageHashProcessingMs: number;
filesPerSecond: number;
uptime: number;
startTime: number;
lastActivityTime: number;
memoryUsage?: {
heapUsed: number;
heapTotal: number;
external: number;
rss: number;
arrayBuffers: number;
};
hashComputationTimes?: Record<string, HashTimingStats>;
pluginProcessingTimes?: Record<string, PluginTimingStats>;
}
export interface DuplicateGroup {
key: string;
files: string[];
}
export interface DuplicateData {
hashDuplicates: DuplicateGroup[];
titleDuplicates: DuplicateGroup[];
stats: {
hashGroupCount: number;
hashFileCount: number;
titleGroupCount: number;
titleFileCount: number;
};
computedAt: number;
computationTimeMs: number;
status?: string;
}
export interface RedisStats {
fileCount: number;
keyCount: number;
totalSize: number;
memoryUsage?: string;
memoryUsageBytes?: number;
}
export interface QueueItem {
path: string;
phase: 'light' | 'hash';
startTime?: number;
// StreamingPipeline stages: validation → metadata → hashing
queue?: 'validation' | 'metadata' | 'hashing';
plugin?: string;
}
// Plugin types
export interface PluginConfigField {
type: string;
description?: string;
default?: unknown;
required?: boolean;
}
export interface PluginMetadataField {
type: string;
description?: string;
}
export interface PluginInfo {
id: string;
name: string;
version: string;
description?: string;
dependencies: string[];
active: boolean;
status: 'loaded' | 'error' | 'unloaded';
error?: string;
config: Record<string, unknown>;
configSchema?: Record<string, PluginConfigField>;
metadataSchema?: Record<string, PluginMetadataField>;
// Queue assignment from manifest or inferred from timing
defaultQueue?: 'fast' | 'background';
}
export interface PluginsResponse {
plugins: PluginInfo[];
executionOrder: string[];
activeCount: number;
totalCount: number;
}
export interface PluginTiming {
pluginId: string;
totalCalls: number;
totalTime: number;
avgTime: number;
minTime: number;
maxTime: number;
}
export interface PluginTimingsResponse {
timings: PluginTiming[];
lastReset: number;
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Formatting utilities for meta-sort-ui
*/
/**
* Format bytes to human readable string
* @param bytes - Number of bytes
* @returns Formatted string like "5.2 GB"
*/
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
/**
* Format number with thousand separators
* @param num - Number to format
* @returns Formatted string like "1,250,000"
*/
export function formatNumber(num: number): string {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
/**
* Format milliseconds to human readable duration
* @param ms - Milliseconds
* @returns Formatted string like "45ms" or "2.5s"
*/
export function formatMs(ms: number): string {
if (ms === 0) return '0ms';
if (ms < 1000) return Math.round(ms) + 'ms';
if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
return (ms / 60000).toFixed(1) + 'm';
}
/**
* Format uptime in hours:minutes format
* @param ms - Uptime in milliseconds
* @returns Formatted string like "2:45"
*/
export function formatUptime(ms: number): string {
const hours = Math.floor(ms / 3600000);
const minutes = Math.floor((ms % 3600000) / 60000);
return `${hours}:${minutes.toString().padStart(2, '0')}`;
}
/**
* Format timestamp to relative "time ago" string
* @param timestamp - Unix timestamp in milliseconds
* @returns Formatted string like "45s ago" or "5m ago"
*/
export function formatTimeAgo(timestamp: number | undefined): string {
if (!timestamp) return '-';
const seconds = Math.round((Date.now() - timestamp) / 1000);
if (seconds < 0) return 'just now';
if (seconds < 60) return `${seconds}s ago`;
if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`;
return `${Math.round(seconds / 3600)}h ago`;
}
/**
* Format duration from start to now
* @param startTime - Start timestamp in milliseconds
* @returns Formatted string like "45s" or "2m"
*/
export function formatDurationFromStart(startTime: number | undefined): string {
if (!startTime) return '-';
const ms = Date.now() - startTime;
return formatMs(ms);
}
/**
* Get filename from full path
* @param filePath - Full file path
* @returns Filename only
*/
export function getFilename(filePath: string): string {
return filePath.split('/').pop() || filePath;
}
/**
* Truncate string in the middle with ellipsis
* @param str - String to truncate
* @param maxLength - Maximum length
* @returns Truncated string
*/
export function truncateMiddle(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
const half = Math.floor((maxLength - 3) / 2);
return str.slice(0, half) + '...' + str.slice(-half);
}