feat: explorador de archivos local y autenticacion/conexion a unidad de red UNC en storage root

This commit is contained in:
Carlos Tello
2026-08-17 00:05:15 -03:00
parent 7badc34816
commit f3b6d8ef71
5 changed files with 457 additions and 25 deletions
+261 -10
View File
@@ -6,7 +6,13 @@ import {
Calendar,
Loader2,
ShieldCheck,
AlertCircle
AlertCircle,
FolderOpen,
ChevronRight,
ArrowUp,
Globe,
RefreshCw,
X
} from 'lucide-react';
import { api, SystemSettingsResponse } from '../services/api';
@@ -38,9 +44,58 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onRefresh }) => {
auth_mode: 'local',
radius_host: '',
radius_port: 1812,
radius_secret: ''
radius_secret: '',
storage_network_enabled: false,
storage_network_user: '',
storage_network_pass: ''
});
const [showExplorer, setShowExplorer] = useState(false);
const [explorerPath, setExplorerPath] = useState('');
const [explorerFolders, setExplorerFolders] = useState<Array<{ name: string; path: string }>>([]);
const [explorerParentPath, setExplorerParentPath] = useState<string | null>(null);
const [explorerLoading, setExplorerLoading] = useState(false);
const [explorerError, setExplorerError] = useState<string | null>(null);
const [testingShare, setTestingShare] = useState(false);
const [hoveredFolder, setHoveredFolder] = useState<string | null>(null);
const loadExplorerPath = async (path?: string) => {
setExplorerLoading(true);
setExplorerError(null);
try {
const data = await api.exploreDir(path);
setExplorerPath(data.current_path);
setExplorerParentPath(data.parent_path);
setExplorerFolders(data.folders);
} catch (err: any) {
setExplorerError(`Error al leer directorio: ${err.message}`);
} finally {
setExplorerLoading(false);
}
};
useEffect(() => {
if (showExplorer) {
loadExplorerPath(explorerPath || formData.storage_root);
}
}, [showExplorer]);
const handleTestNetworkShare = async () => {
setTestingShare(true);
try {
const res = await api.testNetworkShare({
path: formData.storage_root,
username: formData.storage_network_user,
password: formData.storage_network_pass,
});
alert(`✓ Éxito: ${res.message}`);
} catch (err: any) {
alert(`✗ Error de conexión: ${err.message}`);
} finally {
setTestingShare(false);
}
};
const fetchSettings = async () => {
setLoading(true);
setErrorMsg(null);
@@ -117,18 +172,94 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onRefresh }) => {
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label>Directorio Raíz de Backups (Ruta Local o NAS Montado):</label>
<input
type="text"
className="form-input"
required
placeholder="Ej: C:\backups o /mnt/nas/backups"
value={formData.storage_root}
onChange={(e) => setFormData({ ...formData, storage_root: e.target.value })}
/>
<div style={{ display: 'flex', gap: '10px' }}>
<input
type="text"
className="form-input"
required
style={{ flex: 1 }}
placeholder="Ej: C:\backups o \\servidor\compartido"
value={formData.storage_root}
onChange={(e) => setFormData({ ...formData, storage_root: e.target.value })}
/>
<button
type="button"
className="btn btn-secondary"
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
onClick={() => {
setExplorerPath(formData.storage_root);
setShowExplorer(true);
}}
>
<FolderOpen size={16} />
Explorar...
</button>
</div>
<p style={{ fontSize: '0.78rem', color: 'var(--text-dim)', marginTop: '6px', lineHeight: '1.4' }}>
Define la ruta donde el motor de streaming ensamblará y almacenará de forma aislada los archivos de cada cliente.
Asegúrate de que el servicio del backend tenga privilegios de lectura y escritura en este directorio.
</p>
{/* UNC Network share configuration */}
<div style={{ marginTop: '16px', borderTop: '1px dashed rgba(255,255,255,0.08)', paddingTop: '16px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', fontWeight: 600, fontSize: '0.84rem' }}>
<input
type="checkbox"
checked={formData.storage_network_enabled || false}
onChange={(e) => setFormData({ ...formData, storage_network_enabled: e.target.checked })}
/>
Conectar a Unidad en Red (UNC) - Requiere credenciales de acceso
</label>
{formData.storage_network_enabled && (
<div style={{ marginTop: '12px', display: 'flex', flexDirection: 'column', gap: '12px', paddingLeft: '22px' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div className="form-group" style={{ marginBottom: 0 }}>
<label style={{ fontSize: '0.78rem' }}>Usuario de Red:</label>
<input
type="text"
className="form-input"
placeholder="ej: Dominio\Usuario o Administrador"
value={formData.storage_network_user || ''}
onChange={(e) => setFormData({ ...formData, storage_network_user: e.target.value })}
/>
</div>
<div className="form-group" style={{ marginBottom: 0 }}>
<label style={{ fontSize: '0.78rem' }}>Contraseña de Red:</label>
<input
type="password"
className="form-input"
placeholder="Contraseña del recurso compartido"
value={formData.storage_network_pass || ''}
onChange={(e) => setFormData({ ...formData, storage_network_pass: e.target.value })}
/>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-start' }}>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '0.78rem', padding: '6px 12px', display: 'flex', alignItems: 'center', gap: '6px' }}
disabled={testingShare || !formData.storage_root}
onClick={handleTestNetworkShare}
>
{testingShare ? (
<>
<RefreshCw className="animate-spin" size={13} />
Conectando...
</>
) : (
<>
<Globe size={13} />
Probar Conexión de Red
</>
)}
</button>
</div>
</div>
)}
</div>
</div>
</div>
@@ -293,6 +424,126 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onRefresh }) => {
)}
</button>
</div>
{showExplorer && (
<div className="modal-backdrop">
<div className="modal-content" style={{ maxWidth: '600px' }}>
<div className="modal-header">
<h4 style={{ fontSize: '1.1rem', fontWeight: 700, display: 'flex', alignItems: 'center', gap: '8px' }}>
<FolderOpen size={18} color="var(--accent-cyan)" />
Explorador de Archivos del Servidor
</h4>
<button type="button" className="modal-close" onClick={() => setShowExplorer(false)}>
<X size={18} />
</button>
</div>
<div className="modal-body" style={{ padding: '16px 0', display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '6px 10px' }}
disabled={!explorerParentPath}
onClick={() => {
if (explorerParentPath) {
loadExplorerPath(explorerParentPath);
}
}}
title="Subir un nivel"
>
<ArrowUp size={16} />
</button>
<input
type="text"
className="form-input"
style={{ flex: 1 }}
value={explorerPath}
onChange={(e) => setExplorerPath(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
loadExplorerPath(explorerPath);
}
}}
/>
<button
type="button"
className="btn btn-secondary"
onClick={() => loadExplorerPath(explorerPath)}
>
Ir
</button>
</div>
{explorerError && (
<div style={{ padding: '10px 14px', backgroundColor: 'rgba(239,68,68,0.08)', border: '1px solid rgba(239,68,68,0.15)', borderRadius: '6px', fontSize: '0.8rem', color: '#fca5a5' }}>
{explorerError}
</div>
)}
<div style={{
height: '280px',
overflowY: 'auto',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '6px',
backgroundColor: 'rgba(0,0,0,0.15)'
}}>
{explorerLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Loader2 className="animate-spin" size={24} color="var(--accent-cyan)" />
</div>
) : (
<div style={{ padding: '8px' }}>
{explorerFolders.map((folder) => (
<div
key={folder.path}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
cursor: 'pointer',
borderRadius: '4px',
backgroundColor: hoveredFolder === folder.path ? 'rgba(255, 255, 255, 0.06)' : 'transparent',
transition: 'background-color 0.2s'
}}
onClick={() => setExplorerPath(folder.path)}
onDoubleClick={() => loadExplorerPath(folder.path)}
onMouseEnter={() => setHoveredFolder(folder.path)}
onMouseLeave={() => setHoveredFolder(null)}
>
<FolderOpen size={16} color="var(--accent-cyan)" />
<span style={{ fontSize: '0.86rem' }}>{folder.name}</span>
<ChevronRight size={14} color="var(--text-dim)" style={{ marginLeft: 'auto' }} />
</div>
))}
{explorerFolders.length === 0 && !explorerError && (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '200px', color: 'var(--text-dim)', fontSize: '0.86rem' }}>
No hay subcarpetas en este directorio.
</div>
)}
</div>
)}
</div>
</div>
<div className="modal-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: '16px', display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
<button type="button" className="btn btn-secondary" onClick={() => setShowExplorer(false)}>
Cancelar
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => {
setFormData({ ...formData, storage_root: explorerPath });
setShowExplorer(false);
}}
>
Seleccionar Carpeta
</button>
</div>
</div>
</div>
)}
</form>
</div>
);
+12
View File
@@ -113,6 +113,9 @@ export interface SystemSettingsResponse {
radius_host?: string;
radius_port?: number;
radius_secret?: string;
storage_network_enabled?: boolean;
storage_network_user?: string;
storage_network_pass?: string;
}
export interface UserItem {
@@ -239,6 +242,15 @@ export const api = {
method: 'PUT',
body: JSON.stringify(data),
}),
exploreDir: (path?: string) => {
const params = path ? `?path=${encodeURIComponent(path)}` : '';
return request<{ current_path: string; parent_path: string | null; folders: Array<{ name: string; path: string }> }>(`/settings/explore-dir${params}`);
},
testNetworkShare: (data: { path: string; username?: string; password?: string }) =>
request<{ message: string }>('/settings/test-network-share', {
method: 'POST',
body: JSON.stringify(data),
}),
// Jobs
getJobs: (clientId?: number) =>