When NAS storage is enabled, uploaded files go directly to the NAS share
(users/{username}/videos/{title-slug}/) with no permanent local copy kept.
Thumbnails and video are fetched from NAS on demand for streaming/playback.
When NAS is disabled, files are organised into the same directory schema
in local storage.
- VideoController: branch upload flow on NAS enabled/disabled
- NasSyncService: add uploadDirectToNas() for direct NAS writes,
organizeLocalFiles() for local NAS-schema, localVideoDir() resolver,
deleteLocalAssets() for post-sync cleanup
- GenerateHlsJob: download from NAS via ensureLocalCopy() when local
file is absent (NAS-primary mode); clean up temp after HLS generation
- CompressVideoJob: place compressed file alongside original (any dir)
- Video/VideoSlide models: localVideoPath(), localThumbnailPath(),
thumbnailStorageKey(), localPath(), storageKey() helpers for
format-agnostic path resolution (old flat paths + new NAS-schema paths)
- MediaController: serve thumbnails from NAS-mirrored paths with NAS fallback
- SuperAdminController: use model path helpers for file deletion
- NasFreeLocalStorage: scan new users/ tree in addition to legacy flat dirs
- Settings: rename "NAS Storage Sync" tab to "NAS Storage", update description
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
485 lines
22 KiB
PHP
485 lines
22 KiB
PHP
@extends('admin.layout')
|
|
|
|
@section('title', 'System Settings')
|
|
@section('page_title', 'Settings')
|
|
|
|
@section('extra_styles')
|
|
<style>
|
|
.settings-section {
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
margin-bottom: 24px;
|
|
overflow: hidden;
|
|
}
|
|
.settings-section-header {
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 18px 22px;
|
|
border-bottom: 1px solid var(--border);
|
|
font-size: 14px; font-weight: 600;
|
|
}
|
|
.settings-section-header i { color: var(--brand); font-size: 16px; }
|
|
.settings-section-body { padding: 22px; }
|
|
|
|
.setting-row {
|
|
display: flex; align-items: flex-start; justify-content: space-between;
|
|
gap: 24px; padding: 16px 0;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.setting-row:last-child { border-bottom: none; padding-bottom: 0; }
|
|
.setting-row:first-child { padding-top: 0; }
|
|
.setting-label { flex: 1; }
|
|
.setting-label strong { display: block; font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 3px; }
|
|
.setting-label small { font-size: 12px; color: var(--text-2); line-height: 1.5; }
|
|
.setting-control { flex-shrink: 0; min-width: 180px; }
|
|
|
|
/* Toggle switch */
|
|
.toggle-wrap { display: flex; align-items: center; gap: 10px; }
|
|
.toggle-switch {
|
|
position: relative; width: 44px; height: 24px; flex-shrink: 0; cursor: pointer;
|
|
}
|
|
.toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
|
|
.toggle-track {
|
|
position: absolute; inset: 0;
|
|
background: var(--border-light); border-radius: 12px;
|
|
transition: background .2s;
|
|
}
|
|
.toggle-thumb {
|
|
position: absolute; top: 3px; left: 3px;
|
|
width: 18px; height: 18px; border-radius: 50%;
|
|
background: #fff; transition: transform .2s;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,.4);
|
|
}
|
|
.toggle-switch input:checked ~ .toggle-track { background: var(--brand); }
|
|
.toggle-switch input:checked ~ .toggle-thumb { transform: translateX(20px); }
|
|
.toggle-label { font-size: 13px; color: var(--text-2); }
|
|
.toggle-switch input:checked + .toggle-track + .toggle-label { color: var(--text); }
|
|
|
|
/* Select */
|
|
.adm-select-full {
|
|
width: 100%; height: 38px;
|
|
background: var(--bg); border: 1px solid var(--border-light);
|
|
border-radius: 8px; color: var(--text); font-size: 13px;
|
|
padding: 0 12px; outline: none; font-family: inherit;
|
|
transition: border-color .15s; cursor: pointer;
|
|
}
|
|
.adm-select-full:focus { border-color: var(--brand); }
|
|
.adm-select-full option { background: #1e1e1e; }
|
|
|
|
/* GPU cards */
|
|
.gpu-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-bottom: 4px; }
|
|
.gpu-card {
|
|
background: var(--bg); border: 1px solid var(--border);
|
|
border-radius: 10px; padding: 16px;
|
|
cursor: pointer; transition: border-color .15s, background .15s;
|
|
position: relative;
|
|
}
|
|
.gpu-card:hover { border-color: #444; background: var(--bg-card2); }
|
|
.gpu-card.selected { border-color: var(--brand); background: var(--brand-dim); }
|
|
.gpu-card-check {
|
|
position: absolute; top: 10px; right: 10px;
|
|
width: 18px; height: 18px; border-radius: 50%;
|
|
border: 2px solid var(--border); background: transparent;
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-size: 10px; color: #fff;
|
|
transition: background .15s, border-color .15s;
|
|
}
|
|
.gpu-card.selected .gpu-card-check { background: var(--brand); border-color: var(--brand); }
|
|
.gpu-card-name { font-size: 13px; font-weight: 600; margin-bottom: 8px; padding-right: 24px; }
|
|
.gpu-stat { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-2); margin-bottom: 4px; }
|
|
.gpu-stat:last-child { margin-bottom: 0; }
|
|
.gpu-stat-val { color: var(--text); font-weight: 500; }
|
|
|
|
/* Mem bar */
|
|
.mem-bar-wrap { margin-top: 8px; }
|
|
.mem-bar-track { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; }
|
|
.mem-bar-fill { height: 100%; background: var(--brand); border-radius: 2px; transition: width .4s; }
|
|
|
|
/* No GPU state */
|
|
.no-gpu-state { text-align: center; padding: 28px 20px; color: var(--text-2); }
|
|
.no-gpu-state i { font-size: 32px; display: block; margin-bottom: 10px; opacity: .3; }
|
|
|
|
/* Status chip */
|
|
.chip {
|
|
display: inline-flex; align-items: center; gap: 5px;
|
|
padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600;
|
|
}
|
|
.chip-green { background: rgba(34,197,94,.15); color: #4ade80; border: 1px solid rgba(34,197,94,.25); }
|
|
.chip-red { background: rgba(239,68,68,.15); color: #f87171; border: 1px solid rgba(239,68,68,.25); }
|
|
.chip-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
|
|
|
/* Encoder option cards */
|
|
.enc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
|
|
.enc-card {
|
|
background: var(--bg); border: 1px solid var(--border);
|
|
border-radius: 8px; padding: 12px 14px;
|
|
cursor: pointer; transition: border-color .15s;
|
|
text-align: left;
|
|
}
|
|
.enc-card:hover { border-color: #444; }
|
|
.enc-card.selected { border-color: var(--brand); background: var(--brand-dim); }
|
|
.enc-card-name { font-size: 13px; font-weight: 600; display: block; }
|
|
.enc-card-desc { font-size: 11px; color: var(--text-2); margin-top: 3px; display: block; }
|
|
|
|
.save-bar {
|
|
display: flex; align-items: center; justify-content: flex-end;
|
|
gap: 12px; padding-top: 4px;
|
|
}
|
|
</style>
|
|
@endsection
|
|
|
|
@section('content')
|
|
<div class="adm-page-header">
|
|
<h1 class="adm-page-title"><i class="bi bi-gear-fill"></i> System Settings</h1>
|
|
</div>
|
|
|
|
@if(session('success'))
|
|
<div class="adm-alert adm-alert-success" style="margin-bottom:20px;">
|
|
<i class="bi bi-check-circle-fill"></i>
|
|
<span>{{ session('success') }}</span>
|
|
<button class="adm-alert-close" type="button"><i class="bi bi-x"></i></button>
|
|
</div>
|
|
@endif
|
|
|
|
@if($errors->any())
|
|
<div class="adm-alert adm-alert-error" style="margin-bottom:20px;">
|
|
<i class="bi bi-exclamation-triangle-fill"></i>
|
|
<span>{{ $errors->first() }}</span>
|
|
<button class="adm-alert-close" type="button"><i class="bi bi-x"></i></button>
|
|
</div>
|
|
@endif
|
|
|
|
<form method="POST" action="{{ route('admin.settings.update') }}" id="settingsForm">
|
|
@csrf
|
|
|
|
{{-- ── GPU Processing ───────────────────────────────────────── --}}
|
|
<div class="settings-section">
|
|
<div class="settings-section-header">
|
|
<i class="bi bi-gpu-card"></i>
|
|
GPU Accelerated Processing
|
|
<span id="gpuStatusChip" style="margin-left:6px;">
|
|
@if(count($gpus))
|
|
<span class="chip chip-green"><span class="chip-dot"></span> {{ count($gpus) }} GPU{{ count($gpus) > 1 ? 's' : '' }} detected</span>
|
|
@else
|
|
<span class="chip chip-red"><span class="chip-dot"></span> No GPU detected</span>
|
|
@endif
|
|
</span>
|
|
{{-- NVENC encoding health --}}
|
|
<span id="nvencStatusChip" style="margin-left:6px;">
|
|
@if($nvencWorks)
|
|
<span class="chip chip-green"><span class="chip-dot"></span> NVENC encoding ✓</span>
|
|
@else
|
|
<span class="chip chip-red"><span class="chip-dot"></span> NVENC encoding ✗</span>
|
|
@endif
|
|
</span>
|
|
</div>
|
|
|
|
@if(!$nvencWorks && count($gpus))
|
|
<div style="background:rgba(239,68,68,.08);border-left:3px solid #f87171;padding:12px 18px;font-size:13px;color:#f87171;line-height:1.6;">
|
|
<strong>⚠ NVENC is not working with the current FFmpeg binary.</strong><br>
|
|
The GPU is detected but FFmpeg {{ shell_exec('/usr/bin/ffmpeg -version 2>/dev/null | head -1') ?? '' }} cannot initialise CUDA on this driver.<br>
|
|
<strong>Fix:</strong> Install a newer FFmpeg with CUDA 12+ support (e.g. <code>jellyfin-ffmpeg7</code>), then update the binary path below.<br>
|
|
Until then, video encoding will automatically fall back to CPU (libx264).
|
|
</div>
|
|
@endif
|
|
|
|
<div class="settings-section-body">
|
|
|
|
{{-- Detect button + GPU cards --}}
|
|
<div style="margin-bottom:20px;">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
|
|
<span style="font-size:13px;color:var(--text-2);">Available GPUs</span>
|
|
<button type="button" class="adm-btn adm-btn-sm" id="detectBtn" onclick="detectGpus()">
|
|
<i class="bi bi-arrow-repeat" id="detectIcon"></i> Detect GPUs
|
|
</button>
|
|
</div>
|
|
<div id="gpuCardsWrap">
|
|
@if(count($gpus))
|
|
@include('admin.partials.gpu-cards', ['gpus' => $gpus, 'selectedDevice' => $settings['gpu_device']])
|
|
@else
|
|
<div class="no-gpu-state">
|
|
<i class="bi bi-gpu-card"></i>
|
|
<p>No NVIDIA GPUs detected. Click "Detect GPUs" to scan.</p>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Enable GPU --}}
|
|
<div class="setting-row">
|
|
<div class="setting-label">
|
|
<strong>Enable GPU acceleration</strong>
|
|
<small>When enabled, video encoding uses the NVIDIA GPU. When disabled, falls back to CPU (libx264).</small>
|
|
</div>
|
|
<div class="setting-control">
|
|
<label class="toggle-wrap">
|
|
<div class="toggle-switch">
|
|
<input type="checkbox" id="gpuEnabledInput" name="gpu_enabled_check"
|
|
{{ $settings['gpu_enabled'] === 'true' ? 'checked' : '' }}>
|
|
<div class="toggle-track"></div>
|
|
<div class="toggle-thumb"></div>
|
|
</div>
|
|
<span class="toggle-label" id="gpuEnabledLabel">
|
|
{{ $settings['gpu_enabled'] === 'true' ? 'Enabled' : 'Disabled' }}
|
|
</span>
|
|
</label>
|
|
<input type="hidden" name="gpu_enabled" id="gpuEnabledHidden"
|
|
value="{{ $settings['gpu_enabled'] }}">
|
|
</div>
|
|
</div>
|
|
|
|
{{-- GPU device (hidden input, controlled by card click) --}}
|
|
<input type="hidden" name="gpu_device" id="gpuDeviceInput" value="{{ $settings['gpu_device'] }}">
|
|
|
|
{{-- GPU Encoder --}}
|
|
<div class="setting-row" id="gpuEncoderRow">
|
|
<div class="setting-label">
|
|
<strong>Video encoder</strong>
|
|
<small>h264_nvenc is broadly compatible. hevc_nvenc produces smaller files (H.265) but requires compatible players. libx264 forces CPU encoding regardless of the toggle above.</small>
|
|
</div>
|
|
<div class="setting-control">
|
|
<div class="enc-grid">
|
|
@foreach([
|
|
['h264_nvenc', 'H.264 NVENC', 'GPU · max compatibility'],
|
|
['hevc_nvenc', 'H.265 NVENC', 'GPU · smaller files'],
|
|
['libx264', 'libx264', 'CPU · software fallback'],
|
|
] as [$val, $label, $desc])
|
|
<button type="button"
|
|
class="enc-card {{ $settings['gpu_encoder'] === $val ? 'selected' : '' }}"
|
|
data-encoder="{{ $val }}"
|
|
onclick="selectEncoder(this)">
|
|
<span class="enc-card-name">{{ $label }}</span>
|
|
<span class="enc-card-desc">{{ $desc }}</span>
|
|
</button>
|
|
@endforeach
|
|
</div>
|
|
<input type="hidden" name="gpu_encoder" id="gpuEncoderInput" value="{{ $settings['gpu_encoder'] }}">
|
|
</div>
|
|
</div>
|
|
|
|
{{-- Encoding preset --}}
|
|
<div class="setting-row" id="gpuPresetRow">
|
|
<div class="setting-label">
|
|
<strong>Encoding preset</strong>
|
|
<small>NVENC presets: p1 (fastest) → p7 (best quality). libx264 presets: fast / medium / slow. Preset only affects speed vs file size; quality is controlled by CQ/CRF.</small>
|
|
</div>
|
|
<div class="setting-control">
|
|
<select name="gpu_preset" class="adm-select-full" id="gpuPresetSelect">
|
|
<optgroup label="NVENC (GPU)">
|
|
@foreach(['p1','p2','p3','p4','p5','p6','p7'] as $p)
|
|
<option value="{{ $p }}" {{ $settings['gpu_preset'] === $p ? 'selected' : '' }}>
|
|
{{ $p }}{{ $p === 'p4' ? ' — recommended' : '' }}
|
|
</option>
|
|
@endforeach
|
|
</optgroup>
|
|
<optgroup label="libx264 (CPU)">
|
|
@foreach(['fast','medium','slow'] as $p)
|
|
<option value="{{ $p }}" {{ $settings['gpu_preset'] === $p ? 'selected' : '' }}>
|
|
{{ $p }}
|
|
</option>
|
|
@endforeach
|
|
</optgroup>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- HW Accel --}}
|
|
<div class="setting-row" id="gpuHwaccelRow">
|
|
<div class="setting-label">
|
|
<strong>Hardware decode acceleration</strong>
|
|
<small>Use CUDA to decode the source video on the GPU before re-encoding, speeding up the pipeline. Disable if you see CUDA errors in the logs.</small>
|
|
</div>
|
|
<div class="setting-control">
|
|
<select name="gpu_hwaccel" class="adm-select-full">
|
|
<option value="cuda" {{ $settings['gpu_hwaccel'] === 'cuda' ? 'selected' : '' }}>cuda — GPU decode</option>
|
|
<option value="none" {{ $settings['gpu_hwaccel'] === 'none' ? 'selected' : '' }}>none — CPU decode</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- FFmpeg binary path --}}
|
|
<div class="setting-row">
|
|
<div class="setting-label">
|
|
<strong>FFmpeg binary path</strong>
|
|
<small>
|
|
Absolute path to the <code>ffmpeg</code> executable.
|
|
Change this to use a newer build (e.g. <code>/usr/lib/jellyfin-ffmpeg/ffmpeg</code>)
|
|
that supports your GPU driver. Current: <code>{{ config('ffmpeg.ffmpeg', '/usr/bin/ffmpeg') }}</code>
|
|
</small>
|
|
</div>
|
|
<div class="setting-control">
|
|
<input type="text" name="ffmpeg_binary" class="adm-select-full" style="height:auto;padding:8px 12px;"
|
|
value="{{ $settings['ffmpeg_binary'] }}"
|
|
placeholder="/usr/bin/ffmpeg">
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
{{-- ── NAS Storage ───────────────────────────────────────────── --}}
|
|
<div class="settings-section">
|
|
<div class="settings-section-header">
|
|
<i class="bi bi-hdd-network"></i>
|
|
NAS Storage
|
|
@if($settings['nas_sync_enabled'] === 'true')
|
|
<span class="chip chip-green" style="margin-left:6px;"><span class="chip-dot"></span> Enabled</span>
|
|
@else
|
|
<span class="chip chip-red" style="margin-left:6px;"><span class="chip-dot"></span> Disabled</span>
|
|
@endif
|
|
</div>
|
|
<div class="settings-section-body">
|
|
|
|
<div class="setting-row" style="padding-top:0;">
|
|
<div class="setting-label">
|
|
<strong>Use NAS as primary storage</strong>
|
|
<small>
|
|
When enabled, uploads go <strong>directly to the NAS</strong> — no permanent local copy is kept.
|
|
Files are stored at <code>users/{username}/videos/{title-slug}/</code> on the NAS share.
|
|
Thumbnails and video are fetched from NAS on demand for streaming and playback.
|
|
When disabled, files are stored in local storage using the same directory schema.
|
|
Requires the NAS connection to be configured under
|
|
<a href="{{ route('admin.nas-storage') }}" style="color:var(--brand)">NAS Storage</a>.
|
|
</small>
|
|
</div>
|
|
<div class="setting-control">
|
|
<label class="toggle-wrap">
|
|
<div class="toggle-switch">
|
|
<input type="checkbox" id="nasSyncInput" name="nas_sync_enabled_check"
|
|
{{ $settings['nas_sync_enabled'] === 'true' ? 'checked' : '' }}>
|
|
<div class="toggle-track"></div>
|
|
<div class="toggle-thumb"></div>
|
|
</div>
|
|
<span class="toggle-label" id="nasSyncLabel">
|
|
{{ $settings['nas_sync_enabled'] === 'true' ? 'Enabled' : 'Disabled' }}
|
|
</span>
|
|
</label>
|
|
<input type="hidden" name="nas_sync_enabled" id="nasSyncHidden"
|
|
value="{{ $settings['nas_sync_enabled'] }}">
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
{{-- ── Save ─────────────────────────────────────────────────── --}}
|
|
<div class="save-bar">
|
|
<a href="{{ route('admin.dashboard') }}" class="adm-btn">Cancel</a>
|
|
<button type="submit" class="adm-btn adm-btn-primary">
|
|
<i class="bi bi-floppy"></i> Save Settings
|
|
</button>
|
|
</div>
|
|
|
|
</form>
|
|
@endsection
|
|
|
|
@section('scripts')
|
|
<script>
|
|
// ── GPU card selection ────────────────────────────────────────
|
|
function selectGpuCard(el) {
|
|
document.querySelectorAll('.gpu-card').forEach(c => c.classList.remove('selected'));
|
|
el.classList.add('selected');
|
|
document.getElementById('gpuDeviceInput').value = el.dataset.index;
|
|
}
|
|
|
|
// ── Encoder card selection ────────────────────────────────────
|
|
function selectEncoder(el) {
|
|
document.querySelectorAll('.enc-card').forEach(c => c.classList.remove('selected'));
|
|
el.classList.add('selected');
|
|
document.getElementById('gpuEncoderInput').value = el.dataset.encoder;
|
|
// Sync preset optgroup visibility hint
|
|
const isCpu = el.dataset.encoder === 'libx264';
|
|
document.getElementById('gpuPresetSelect').value = isCpu ? 'fast' : 'p4';
|
|
}
|
|
|
|
// ── NAS sync toggle ───────────────────────────────────────────
|
|
const nasToggle = document.getElementById('nasSyncInput');
|
|
const nasHidden = document.getElementById('nasSyncHidden');
|
|
const nasLabel = document.getElementById('nasSyncLabel');
|
|
nasToggle.addEventListener('change', () => {
|
|
nasHidden.value = nasToggle.checked ? 'true' : 'false';
|
|
nasLabel.textContent = nasToggle.checked ? 'Enabled' : 'Disabled';
|
|
});
|
|
|
|
// ── GPU toggle ────────────────────────────────────────────────
|
|
const gpuToggle = document.getElementById('gpuEnabledInput');
|
|
const gpuHidden = document.getElementById('gpuEnabledHidden');
|
|
const gpuLabel = document.getElementById('gpuEnabledLabel');
|
|
|
|
function applyGpuToggle() {
|
|
const on = gpuToggle.checked;
|
|
gpuHidden.value = on ? 'true' : 'false';
|
|
gpuLabel.textContent = on ? 'Enabled' : 'Disabled';
|
|
}
|
|
gpuToggle.addEventListener('change', applyGpuToggle);
|
|
|
|
// ── Live GPU detect ───────────────────────────────────────────
|
|
async function detectGpus() {
|
|
const btn = document.getElementById('detectBtn');
|
|
const icon = document.getElementById('detectIcon');
|
|
btn.disabled = true;
|
|
icon.className = 'bi bi-arrow-repeat spin';
|
|
|
|
try {
|
|
const res = await fetch('{{ route('admin.settings.detect-gpu') }}');
|
|
const data = await res.json();
|
|
const gpus = data.gpus || [];
|
|
const wrap = document.getElementById('gpuCardsWrap');
|
|
const chip = document.getElementById('gpuStatusChip');
|
|
const selectedDevice = parseInt(document.getElementById('gpuDeviceInput').value);
|
|
|
|
if (gpus.length === 0) {
|
|
wrap.innerHTML = '<div class="no-gpu-state"><i class="bi bi-gpu-card"></i><p>No NVIDIA GPUs detected.</p></div>';
|
|
chip.innerHTML = '<span class="chip chip-red"><span class="chip-dot"></span> No GPU detected</span>';
|
|
} else {
|
|
chip.innerHTML = `<span class="chip chip-green"><span class="chip-dot"></span> ${gpus.length} GPU${gpus.length > 1 ? 's' : ''} detected</span>`;
|
|
wrap.innerHTML = buildGpuCards(gpus, selectedDevice);
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
|
|
btn.disabled = false;
|
|
icon.className = 'bi bi-arrow-repeat';
|
|
}
|
|
|
|
function buildGpuCards(gpus, selectedDevice) {
|
|
if (!gpus.length) return '';
|
|
const grid = document.createElement('div');
|
|
grid.className = 'gpu-grid';
|
|
|
|
gpus.forEach(gpu => {
|
|
const used = gpu.mem_total - gpu.mem_free;
|
|
const usedPct = Math.round((used / gpu.mem_total) * 100);
|
|
const sel = gpu.index === selectedDevice;
|
|
const card = document.createElement('div');
|
|
card.className = 'gpu-card' + (sel ? ' selected' : '');
|
|
card.dataset.index = gpu.index;
|
|
card.onclick = function() { selectGpuCard(this); };
|
|
card.innerHTML = `
|
|
<div class="gpu-card-check">${sel ? '<i class="bi bi-check"></i>' : ''}</div>
|
|
<div class="gpu-card-name">${escHtml(gpu.name)}</div>
|
|
<div class="gpu-stat"><span>VRAM</span><span class="gpu-stat-val">${gpu.mem_total.toLocaleString()} MB</span></div>
|
|
<div class="gpu-stat"><span>Free</span><span class="gpu-stat-val">${gpu.mem_free.toLocaleString()} MB</span></div>
|
|
<div class="gpu-stat"><span>GPU Load</span><span class="gpu-stat-val">${gpu.util}%</span></div>
|
|
<div class="gpu-stat"><span>Temp</span><span class="gpu-stat-val">${gpu.temp} °C</span></div>
|
|
<div class="gpu-stat"><span>Driver</span><span class="gpu-stat-val">${escHtml(gpu.driver)}</span></div>
|
|
<div class="mem-bar-wrap">
|
|
<div class="mem-bar-track"><div class="mem-bar-fill" style="width:${usedPct}%"></div></div>
|
|
</div>`;
|
|
grid.appendChild(card);
|
|
});
|
|
|
|
return grid.outerHTML;
|
|
}
|
|
|
|
function escHtml(s) {
|
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
</script>
|
|
<style>
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.spin { display: inline-block; animation: spin .6s linear infinite; }
|
|
</style>
|
|
@endsection
|