feat: explorador de archivos local y autenticacion/conexion a unidad de red UNC en storage root
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from typing import Optional
|
||||||
|
import os
|
||||||
|
import string
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.api.deps import require_admin
|
from app.api.deps import require_admin
|
||||||
from app.models.models import User
|
from app.models.models import User
|
||||||
@@ -8,6 +15,11 @@ from app.services.settings_service import get_all_settings, update_settings_serv
|
|||||||
|
|
||||||
router = APIRouter(prefix="/settings", tags=["System Settings"])
|
router = APIRouter(prefix="/settings", tags=["System Settings"])
|
||||||
|
|
||||||
|
class TestShareRequest(BaseModel):
|
||||||
|
path: str
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
@router.get("", response_model=SystemSettingsResponse)
|
@router.get("", response_model=SystemSettingsResponse)
|
||||||
async def get_settings(
|
async def get_settings(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -22,6 +34,13 @@ async def get_settings(
|
|||||||
"default_keep_daily": int(settings_dict.get("default_keep_daily", 7)),
|
"default_keep_daily": int(settings_dict.get("default_keep_daily", 7)),
|
||||||
"default_keep_weekly": int(settings_dict.get("default_keep_weekly", 4)),
|
"default_keep_weekly": int(settings_dict.get("default_keep_weekly", 4)),
|
||||||
"default_keep_monthly": int(settings_dict.get("default_keep_monthly", 12)),
|
"default_keep_monthly": int(settings_dict.get("default_keep_monthly", 12)),
|
||||||
|
"auth_mode": settings_dict.get("auth_mode", "local"),
|
||||||
|
"radius_host": settings_dict.get("radius_host", ""),
|
||||||
|
"radius_port": int(settings_dict.get("radius_port", 1812)) if settings_dict.get("radius_port") else 1812,
|
||||||
|
"radius_secret": settings_dict.get("radius_secret", ""),
|
||||||
|
"storage_network_enabled": settings_dict.get("storage_network_enabled", "false").lower() == "true",
|
||||||
|
"storage_network_user": settings_dict.get("storage_network_user", ""),
|
||||||
|
"storage_network_pass": settings_dict.get("storage_network_pass", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.put("", response_model=SystemSettingsResponse)
|
@router.put("", response_model=SystemSettingsResponse)
|
||||||
@@ -49,4 +68,95 @@ async def update_settings(
|
|||||||
"default_keep_daily": int(settings_dict.get("default_keep_daily", 7)),
|
"default_keep_daily": int(settings_dict.get("default_keep_daily", 7)),
|
||||||
"default_keep_weekly": int(settings_dict.get("default_keep_weekly", 4)),
|
"default_keep_weekly": int(settings_dict.get("default_keep_weekly", 4)),
|
||||||
"default_keep_monthly": int(settings_dict.get("default_keep_monthly", 12)),
|
"default_keep_monthly": int(settings_dict.get("default_keep_monthly", 12)),
|
||||||
|
"auth_mode": settings_dict.get("auth_mode", "local"),
|
||||||
|
"radius_host": settings_dict.get("radius_host", ""),
|
||||||
|
"radius_port": int(settings_dict.get("radius_port", 1812)) if settings_dict.get("radius_port") else 1812,
|
||||||
|
"radius_secret": settings_dict.get("radius_secret", ""),
|
||||||
|
"storage_network_enabled": settings_dict.get("storage_network_enabled", "false").lower() == "true",
|
||||||
|
"storage_network_user": settings_dict.get("storage_network_user", ""),
|
||||||
|
"storage_network_pass": settings_dict.get("storage_network_pass", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@router.get("/explore-dir")
|
||||||
|
async def explore_directory(
|
||||||
|
path: Optional[str] = None,
|
||||||
|
admin_user: User = Depends(require_admin)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Explore directories on the host filesystem.
|
||||||
|
"""
|
||||||
|
import platform
|
||||||
|
|
||||||
|
if not path or path.strip() == "":
|
||||||
|
if platform.system().lower() == "windows":
|
||||||
|
drives = []
|
||||||
|
for letter in string.ascii_uppercase:
|
||||||
|
drive_path = f"{letter}:\\"
|
||||||
|
if os.path.exists(drive_path):
|
||||||
|
drives.append({"name": drive_path, "path": drive_path})
|
||||||
|
return {"current_path": "", "folders": drives}
|
||||||
|
else:
|
||||||
|
return {"current_path": "/", "folders": [{"name": "/", "path": "/"}]}
|
||||||
|
|
||||||
|
# Resolve path
|
||||||
|
target = Path(path).resolve()
|
||||||
|
if not target.exists() or not target.is_dir():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"The path '{path}' does not exist or is not a directory."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
folders = []
|
||||||
|
for entry in os.scandir(target):
|
||||||
|
try:
|
||||||
|
if entry.is_dir():
|
||||||
|
folders.append({
|
||||||
|
"name": entry.name,
|
||||||
|
"path": str(Path(entry.path).resolve())
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
continue # Skip inaccessible
|
||||||
|
|
||||||
|
folders.sort(key=lambda x: x["name"].lower())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"current_path": str(target),
|
||||||
|
"parent_path": str(target.parent) if target.parent != target else None,
|
||||||
|
"folders": folders
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Failed to read directory contents: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/test-network-share")
|
||||||
|
async def test_network_share(
|
||||||
|
payload: TestShareRequest,
|
||||||
|
admin_user: User = Depends(require_admin)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Attempts to connect to a UNC network share and tests write access.
|
||||||
|
"""
|
||||||
|
from app.services.settings_service import connect_network_share
|
||||||
|
|
||||||
|
ok = connect_network_share(payload.path, payload.username, payload.password)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Failed to connect to the network share. Check the server path, username, and password."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
target_path = Path(payload.path)
|
||||||
|
os.makedirs(target_path, exist_ok=True)
|
||||||
|
test_file = target_path / f".write_test_{uuid.uuid4().hex[:6]}"
|
||||||
|
test_file.touch()
|
||||||
|
test_file.unlink()
|
||||||
|
return {"message": "Network share connected and write access verified successfully!"}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Network share connected, but write access test failed: {str(e)}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -256,6 +256,13 @@ class SystemSettingsResponse(BaseModel):
|
|||||||
default_keep_daily: int
|
default_keep_daily: int
|
||||||
default_keep_weekly: int
|
default_keep_weekly: int
|
||||||
default_keep_monthly: int
|
default_keep_monthly: int
|
||||||
|
auth_mode: Optional[str] = "local"
|
||||||
|
radius_host: Optional[str] = ""
|
||||||
|
radius_port: Optional[int] = 1812
|
||||||
|
radius_secret: Optional[str] = ""
|
||||||
|
storage_network_enabled: Optional[bool] = False
|
||||||
|
storage_network_user: Optional[str] = ""
|
||||||
|
storage_network_pass: Optional[str] = ""
|
||||||
|
|
||||||
class SystemSettingsUpdate(BaseModel):
|
class SystemSettingsUpdate(BaseModel):
|
||||||
storage_root: Optional[str] = None
|
storage_root: Optional[str] = None
|
||||||
@@ -264,6 +271,13 @@ class SystemSettingsUpdate(BaseModel):
|
|||||||
default_keep_daily: Optional[int] = None
|
default_keep_daily: Optional[int] = None
|
||||||
default_keep_weekly: Optional[int] = None
|
default_keep_weekly: Optional[int] = None
|
||||||
default_keep_monthly: Optional[int] = None
|
default_keep_monthly: Optional[int] = None
|
||||||
|
auth_mode: Optional[str] = None
|
||||||
|
radius_host: Optional[str] = None
|
||||||
|
radius_port: Optional[int] = None
|
||||||
|
radius_secret: Optional[str] = None
|
||||||
|
storage_network_enabled: Optional[bool] = None
|
||||||
|
storage_network_user: Optional[str] = None
|
||||||
|
storage_network_pass: Optional[str] = None
|
||||||
|
|
||||||
class AgentJobRegister(BaseModel):
|
class AgentJobRegister(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
|||||||
@@ -16,8 +16,40 @@ DEFAULT_SETTINGS = {
|
|||||||
"radius_host": "",
|
"radius_host": "",
|
||||||
"radius_port": "1812",
|
"radius_port": "1812",
|
||||||
"radius_secret": "",
|
"radius_secret": "",
|
||||||
|
"storage_network_enabled": "false",
|
||||||
|
"storage_network_user": "",
|
||||||
|
"storage_network_pass": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def connect_network_share(path: str, username: str, password: str) -> bool:
|
||||||
|
"""
|
||||||
|
Mounts a UNC network share on Windows using 'net use'.
|
||||||
|
"""
|
||||||
|
if not (path.startswith(r"\\") or path.startswith("//")):
|
||||||
|
return True
|
||||||
|
|
||||||
|
parts = [p for p in path.replace("/", "\\").split("\\") if p]
|
||||||
|
if len(parts) < 2:
|
||||||
|
return False
|
||||||
|
unc_base = f"\\\\{parts[0]}\\{parts[1]}"
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
print(f"Connecting to network share {unc_base} with user '{username}'...")
|
||||||
|
|
||||||
|
# 1. Remove existing connection if any (ignore errors)
|
||||||
|
subprocess.run(["net", "use", unc_base, "/delete", "/y"], capture_output=True)
|
||||||
|
|
||||||
|
# 2. Add connection
|
||||||
|
cmd = ["net", "use", unc_base]
|
||||||
|
if password:
|
||||||
|
cmd.append(password)
|
||||||
|
if username:
|
||||||
|
cmd.append(f"/user:{username}")
|
||||||
|
cmd.append("/persistent:yes")
|
||||||
|
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
async def get_setting(db: AsyncSession, key: str) -> str:
|
async def get_setting(db: AsyncSession, key: str) -> str:
|
||||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||||
setting = result.scalar_one_or_none()
|
setting = result.scalar_one_or_none()
|
||||||
@@ -43,23 +75,36 @@ async def get_all_settings(db: AsyncSession) -> dict:
|
|||||||
async def update_settings_service(db: AsyncSession, new_settings: dict) -> dict:
|
async def update_settings_service(db: AsyncSession, new_settings: dict) -> dict:
|
||||||
from app.storage.local import storage_provider
|
from app.storage.local import storage_provider
|
||||||
|
|
||||||
|
current = await get_all_settings(db)
|
||||||
|
merged = {**current, **new_settings}
|
||||||
|
|
||||||
|
# Connect network share if enabled and it's a UNC path
|
||||||
|
net_enabled = str(merged.get("storage_network_enabled", "false")).lower() == "true"
|
||||||
|
net_user = merged.get("storage_network_user", "")
|
||||||
|
net_pass = merged.get("storage_network_pass", "")
|
||||||
|
root_path = merged.get("storage_root", "")
|
||||||
|
|
||||||
|
if net_enabled and root_path and (root_path.startswith(r"\\") or root_path.startswith("//")):
|
||||||
|
ok = connect_network_share(root_path, net_user, net_pass)
|
||||||
|
if not ok:
|
||||||
|
raise ValueError("Failed to authenticate or connect to the specified network share (UNC). Verify path and credentials.")
|
||||||
|
|
||||||
|
# Perform standard storage root validation
|
||||||
|
if "storage_root" in new_settings:
|
||||||
|
v = new_settings["storage_root"]
|
||||||
|
path = Path(v).resolve()
|
||||||
|
try:
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
test_file = path / ".write_test"
|
||||||
|
test_file.touch()
|
||||||
|
test_file.unlink()
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Invalid or unwritable storage root path: {str(e)}")
|
||||||
|
|
||||||
|
storage_provider.root_dir = path
|
||||||
|
|
||||||
for k, v in new_settings.items():
|
for k, v in new_settings.items():
|
||||||
if k in DEFAULT_SETTINGS and v is not None:
|
if k in DEFAULT_SETTINGS and v is not None:
|
||||||
# If changing storage root, validate and apply
|
|
||||||
if k == "storage_root":
|
|
||||||
path = Path(v).resolve()
|
|
||||||
try:
|
|
||||||
os.makedirs(path, exist_ok=True)
|
|
||||||
# Test write access
|
|
||||||
test_file = path / ".write_test"
|
|
||||||
test_file.touch()
|
|
||||||
test_file.unlink()
|
|
||||||
except Exception as e:
|
|
||||||
raise ValueError(f"Invalid or unwritable storage root path: {str(e)}")
|
|
||||||
|
|
||||||
# Apply to in-memory storage provider
|
|
||||||
storage_provider.root_dir = path
|
|
||||||
|
|
||||||
result = await db.execute(select(SystemSetting).where(SystemSetting.key == k))
|
result = await db.execute(select(SystemSetting).where(SystemSetting.key == k))
|
||||||
setting = result.scalar_one_or_none()
|
setting = result.scalar_one_or_none()
|
||||||
if setting:
|
if setting:
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
Loader2,
|
Loader2,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
AlertCircle
|
AlertCircle,
|
||||||
|
FolderOpen,
|
||||||
|
ChevronRight,
|
||||||
|
ArrowUp,
|
||||||
|
Globe,
|
||||||
|
RefreshCw,
|
||||||
|
X
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { api, SystemSettingsResponse } from '../services/api';
|
import { api, SystemSettingsResponse } from '../services/api';
|
||||||
|
|
||||||
@@ -38,9 +44,58 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onRefresh }) => {
|
|||||||
auth_mode: 'local',
|
auth_mode: 'local',
|
||||||
radius_host: '',
|
radius_host: '',
|
||||||
radius_port: 1812,
|
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 () => {
|
const fetchSettings = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setErrorMsg(null);
|
setErrorMsg(null);
|
||||||
@@ -117,18 +172,94 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onRefresh }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||||
<label>Directorio Raíz de Backups (Ruta Local o NAS Montado):</label>
|
<label>Directorio Raíz de Backups (Ruta Local o NAS Montado):</label>
|
||||||
<input
|
<div style={{ display: 'flex', gap: '10px' }}>
|
||||||
type="text"
|
<input
|
||||||
className="form-input"
|
type="text"
|
||||||
required
|
className="form-input"
|
||||||
placeholder="Ej: C:\backups o /mnt/nas/backups"
|
required
|
||||||
value={formData.storage_root}
|
style={{ flex: 1 }}
|
||||||
onChange={(e) => setFormData({ ...formData, storage_root: e.target.value })}
|
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' }}>
|
<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.
|
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.
|
Asegúrate de que el servicio del backend tenga privilegios de lectura y escritura en este directorio.
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -293,6 +424,126 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onRefresh }) => {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -113,6 +113,9 @@ export interface SystemSettingsResponse {
|
|||||||
radius_host?: string;
|
radius_host?: string;
|
||||||
radius_port?: number;
|
radius_port?: number;
|
||||||
radius_secret?: string;
|
radius_secret?: string;
|
||||||
|
storage_network_enabled?: boolean;
|
||||||
|
storage_network_user?: string;
|
||||||
|
storage_network_pass?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserItem {
|
export interface UserItem {
|
||||||
@@ -239,6 +242,15 @@ export const api = {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
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
|
// Jobs
|
||||||
getJobs: (clientId?: number) =>
|
getJobs: (clientId?: number) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user