initial commit
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
Reference in New Issue
Block a user