953 lines
60 KiB
PHP
953 lines
60 KiB
PHP
<x-proxmox::layout
|
|
:server="$server"
|
|
view="overview"
|
|
:status="$status"
|
|
:status-polling="true"
|
|
:status-polling-interval="'2s'"
|
|
>
|
|
@if(!$status)
|
|
{{-- Error state when status cannot be loaded --}}
|
|
<x-proxmox::ui.alert variant="danger" icon="ri-error-warning-fill" class="mt-8">
|
|
<x-slot name="title">Unable to Load Server Status</x-slot>
|
|
<p class="text-gray-600 dark:text-gray-400 mb-3">Could not retrieve status from hypervisor:</p>
|
|
<ul class="list-disc list-inside text-gray-600 dark:text-gray-400 mb-3 space-y-1 text-sm">
|
|
<li>Temporary connectivity issues with the Proxmox cluster</li>
|
|
<li>The VM may be in an intermediate state</li>
|
|
<li>Network timeout while fetching data</li>
|
|
</ul>
|
|
<x-proxmox::ui.button wire:click="loadVmStatus" icon="ri-refresh-line">
|
|
Retry
|
|
</x-proxmox::ui.button>
|
|
</x-proxmox::ui.alert>
|
|
@else
|
|
<script>
|
|
function copyToClipboard(text, buttonEl) {
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
// Change button style & text
|
|
const originalText = buttonEl.innerText;
|
|
buttonEl.innerText = "Copied!";
|
|
buttonEl.classList.remove("bg-blue-600", "hover:bg-blue-700");
|
|
buttonEl.classList.add("bg-green-600", "hover:bg-green-700");
|
|
|
|
// Revert after 2s
|
|
setTimeout(() => {
|
|
buttonEl.innerText = originalText;
|
|
buttonEl.classList.remove("bg-green-600", "hover:bg-green-700");
|
|
buttonEl.classList.add("bg-blue-600", "hover:bg-blue-700");
|
|
}, 2000);
|
|
}).catch(err => {
|
|
console.error("Failed to copy: ", err);
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<div class="rounded-lg border-l-4 border-blue-500 bg-blue-50 p-3 mb-4 dark:bg-blue-950 dark:border-blue-400"
|
|
x-data="{
|
|
isCollapsed: localStorage.getItem('overview-notice-collapsed') === 'true',
|
|
isAdvancedView: localStorage.getItem('proxmox-view-mode') === 'advanced',
|
|
toggle() {
|
|
this.isCollapsed = !this.isCollapsed;
|
|
localStorage.setItem('overview-notice-collapsed', this.isCollapsed);
|
|
}
|
|
}"
|
|
x-init="
|
|
isAdvancedView = localStorage.getItem('proxmox-view-mode') === 'advanced';
|
|
window.addEventListener('proxmox-view-mode-changed', (e) => {
|
|
isAdvancedView = e.detail.isAdvancedView;
|
|
});
|
|
"
|
|
x-show="isAdvancedView"
|
|
x-transition>
|
|
<div class="flex items-start gap-2">
|
|
<x-ri-information-line class="size-4 text-blue-600 flex-shrink-0 mt-0.5" />
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Cloud-init Configuration</h3>
|
|
<button @click="toggle()" type="button" class="text-gray-500 hover:text-gray-700 dark:text-gray-400">
|
|
<x-ri-arrow-down-s-line x-bind:class="isCollapsed ? 'size-4 rotate-180' : 'size-4'" />
|
|
</button>
|
|
</div>
|
|
<div x-show="!isCollapsed" x-transition class="mt-1">
|
|
<p class="text-xs text-gray-600 dark:text-gray-300">
|
|
<strong>Hostname, password, and networking</strong> are configured via <strong>cloud-init</strong>. Manual config required for:
|
|
</p>
|
|
<ul class="text-xs text-gray-600 dark:text-gray-300 mt-1 ml-4 list-disc">
|
|
<li>ISO installations</li>
|
|
<li>Manual OS configuration</li>
|
|
<li>Windows (no cloud-init)</li>
|
|
</ul>
|
|
<p class="text-xs text-gray-600 dark:text-gray-300 mt-1">
|
|
You will need to <strong>manually configure networking, security settings, and hostname</strong> within the operating system, as cloud-init will not be available in these scenarios.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center" wire:poll.{{ $fastPolling ? '2s' : '30s' }}="checkStatus"
|
|
wire:key="{{ $fastPolling ? 'fast' : 'slow' }}">
|
|
<div class="flex items-center gap-3">
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
<h2 class="text-lg font-semibold">Server Details</h2>
|
|
<div class="relative" x-data="{ open: false }" x-cloak>
|
|
<button type="button" @click="open = true" class="text-gray-400 hover:text-gray-600 dark:text-gray-500">
|
|
<x-ri-question-line class="size-4" />
|
|
</button>
|
|
<!-- Modal Backdrop -->
|
|
<div x-show="open"
|
|
x-transition:enter="transition ease-out duration-200"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
x-transition:leave="transition ease-in duration-150"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
@click="open = false"
|
|
@keydown.escape.window="open = false"
|
|
class="fixed inset-0 z-[60] bg-gray-900 bg-opacity-50"
|
|
style="display: none;">
|
|
</div>
|
|
<!-- Modal -->
|
|
<div x-show="open"
|
|
x-transition:enter="transition ease-out duration-200"
|
|
x-transition:enter-start="opacity-0 scale-95"
|
|
x-transition:enter-end="opacity-100 scale-100"
|
|
x-transition:leave="transition ease-in duration-150"
|
|
x-transition:leave-start="opacity-100 scale-100"
|
|
x-transition:leave-end="opacity-0 scale-95"
|
|
@click.away="open = false"
|
|
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
|
style="display: none;">
|
|
<div class="relative w-full max-w-xl max-h-[90vh] overflow-y-auto bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl p-5">
|
|
<button @click="open = false" class="absolute top-3 right-3 text-gray-400 hover:text-gray-600 dark:text-gray-500">
|
|
<x-ri-close-line class="size-5" />
|
|
</button>
|
|
<div class="flex items-center gap-2 mb-3 pr-6">
|
|
<div class="flex items-center justify-center w-8 h-8 rounded bg-blue-100 dark:bg-blue-900">
|
|
<x-ri-server-line class="size-4 text-blue-600 dark:text-blue-400" />
|
|
</div>
|
|
<h6 class="text-base font-semibold text-gray-800 dark:text-gray-100">Server Details</h6>
|
|
</div>
|
|
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
|
VM information: status, resources, network config.
|
|
</p>
|
|
<div class="pt-3 border-t border-gray-200 dark:border-gray-700 space-y-1.5 text-xs text-gray-600 dark:text-gray-400">
|
|
<p><span class="font-semibold text-gray-700 dark:text-gray-300">Status:</span> Power state with real-time updates.</p>
|
|
<p><span class="font-semibold text-gray-700 dark:text-gray-300">Resources:</span> CPU, memory, disk, network stats.</p>
|
|
<p><span class="font-semibold text-gray-700 dark:text-gray-300">Network:</span> IP addresses and interface info.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<style>
|
|
/* TailAdmin Progress Bar Styles */
|
|
progress {
|
|
border-radius: 8px;
|
|
background-color: #f1f5f9;
|
|
border: none;
|
|
width: 100%;
|
|
overflow: hidden;
|
|
height: 8px;
|
|
}
|
|
|
|
.dark progress {
|
|
background-color: #334155;
|
|
}
|
|
|
|
progress[value]::-webkit-progress-bar {
|
|
background-color: #f1f5f9;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.dark progress[value]::-webkit-progress-bar {
|
|
background-color: #334155;
|
|
}
|
|
|
|
progress[value]#memory::-webkit-progress-value {
|
|
background: linear-gradient(90deg, #22c55e 0%, #10b981 100%);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
progress[value]#memory::-moz-progress-bar {
|
|
background: linear-gradient(90deg, #22c55e 0%, #10b981 100%);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
progress[value]#cpu::-webkit-progress-value {
|
|
background: linear-gradient(90deg, #465fff 0%, #818cf8 100%);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
progress[value]#cpu::-moz-progress-bar {
|
|
background: linear-gradient(90deg, #465fff 0%, #818cf8 100%);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.formRight select {
|
|
background-color: white;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 8px;
|
|
padding: 0.625rem 1rem;
|
|
font-size: 0.875rem;
|
|
line-height: 1.25rem;
|
|
outline: none;
|
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.dark .formRight select {
|
|
background-color: #1e293b;
|
|
border-color: #334155;
|
|
}
|
|
|
|
.formRight select:focus {
|
|
outline: none;
|
|
border-color: #465fff;
|
|
box-shadow: 0 0 0 3px rgba(70, 95, 255, 0.1);
|
|
}
|
|
</style>
|
|
|
|
</div>
|
|
|
|
@php
|
|
$cpuCores = max(1, (int)($status->cpus ?? 1));
|
|
$cpuUsagePercent = round(max(0, ($status->cpu ?? 0) * 100), 1);
|
|
$memoryTotal = (int)($status->maxmem ?? 0);
|
|
$memoryUsed = (int)($status->mem ?? 0);
|
|
$memoryPercent = $memoryTotal > 0 ? round(($memoryUsed / $memoryTotal) * 100, 1) : 0;
|
|
$manualSnapshotLimit = 5;
|
|
$manualSnapshotUsed = $server->statistics()->where('is_automatic', false)->count();
|
|
$manualSnapshotPercent = $manualSnapshotLimit > 0
|
|
? round(($manualSnapshotUsed / $manualSnapshotLimit) * 100, 1)
|
|
: 0;
|
|
|
|
$badgeForPercent = function (float $value) {
|
|
if ($value >= 100) {
|
|
return ['label' => 'Over entitlement', 'class' => 'text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-900'];
|
|
}
|
|
|
|
if ($value >= 85) {
|
|
return ['label' => 'Watchlist', 'class' => 'text-amber-600 bg-amber-100 dark:text-amber-400 dark:bg-amber-900'];
|
|
}
|
|
|
|
return ['label' => 'Healthy', 'class' => 'text-emerald-600 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-900'];
|
|
};
|
|
|
|
$cpuBadge = $badgeForPercent($cpuUsagePercent);
|
|
$memoryBadge = $badgeForPercent($memoryPercent);
|
|
$reportedUptime = $this->status && isset($this->status->uptime)
|
|
? $this->formatSeconds($this->status->uptime)
|
|
: 'N/A';
|
|
|
|
$powerStats = $this->powerEventStats ?? [
|
|
'starts' => 0,
|
|
'shutdowns' => 0,
|
|
'reboots' => 0,
|
|
'uptime_percent' => 0,
|
|
'samples' => 0,
|
|
];
|
|
|
|
$resourceAllocations = [
|
|
[
|
|
'label' => 'CPU Allocation',
|
|
'used_percent' => $cpuUsagePercent,
|
|
'usage_summary' => number_format(($status->cpu ?? 0) * $cpuCores, 2) . ' / ' . $cpuCores . ' vCPU',
|
|
'entitlement_label' => $cpuCores . ' vCPU entitlement',
|
|
'description' => 'Live hypervisor usage sampled directly from the cluster.',
|
|
'gradient' => 'linear-gradient(90deg, #38bdf8 0%, #6366f1 100%)',
|
|
'badge' => $cpuBadge,
|
|
],
|
|
[
|
|
'label' => 'Memory Allocation',
|
|
'used_percent' => $memoryPercent,
|
|
'usage_summary' => ($memoryTotal > 0
|
|
? \Illuminate\Support\Number::fileSize(bytes: $memoryUsed) . ' / ' . \Illuminate\Support\Number::fileSize(bytes: $memoryTotal)
|
|
: \Illuminate\Support\Number::fileSize(bytes: $memoryUsed) . ' / N/A'),
|
|
'entitlement_label' => $memoryTotal > 0
|
|
? \Illuminate\Support\Number::fileSize(bytes: $memoryTotal) . ' entitlement'
|
|
: 'Entitlement unavailable',
|
|
'description' => 'Calculated from the VPS\'s provisioned RAM.',
|
|
'gradient' => 'linear-gradient(90deg, #34d399 0%, #10b981 100%)',
|
|
'badge' => $memoryBadge,
|
|
],
|
|
[
|
|
'label' => 'Hypervisor Uptime',
|
|
'used_percent' => null,
|
|
'usage_summary' => $reportedUptime,
|
|
'entitlement_label' => ($powerStats['starts'] ?? 0) . ' starts • ' . ($powerStats['shutdowns'] ?? 0) . ' shutdowns • ' . ($powerStats['reboots'] ?? 0) . ' reboots',
|
|
'description' => 'Shows the live uptime reported by the hypervisor plus counts from the task history.',
|
|
'gradient' => 'linear-gradient(90deg, #a855f7 0%, #ec4899 100%)',
|
|
'badge' => ['label' => 'Live', 'class' => 'text-blue-600 bg-blue-100 dark:text-blue-400 dark:bg-blue-900'],
|
|
],
|
|
];
|
|
|
|
$networkRates = $this->networkRateStats ?? ['in_rate' => 0, 'out_rate' => 0];
|
|
$formatRate = function ($bytesPerSecond) {
|
|
if ($bytesPerSecond >= 1073741824) {
|
|
return number_format($bytesPerSecond / 1073741824, 2) . ' GiB/s';
|
|
}
|
|
if ($bytesPerSecond >= 1048576) {
|
|
return number_format($bytesPerSecond / 1048576, 2) . ' MiB/s';
|
|
}
|
|
if ($bytesPerSecond >= 1024) {
|
|
return number_format($bytesPerSecond / 1024, 2) . ' KiB/s';
|
|
}
|
|
|
|
return number_format($bytesPerSecond, 0) . ' B/s';
|
|
};
|
|
$rateScale = 5 * 1024 * 1024; // 5 MiB/s caps the bar width
|
|
$aggregateRate = ($networkRates['in_rate'] ?? 0) + ($networkRates['out_rate'] ?? 0);
|
|
$totalNetworkBytes = (int) (($status->netin ?? 0) + ($status->netout ?? 0));
|
|
$totalNetworkFormatted = number_format($totalNetworkBytes / 1024 / 1024, 2);
|
|
$lastSampleTimestamp = now()->toIso8601String();
|
|
@endphp
|
|
|
|
<x-proxmox::ui.card class="mt-4">
|
|
<div class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between mb-4">
|
|
<div class="flex items-center gap-2">
|
|
<h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Resource Allocation</h3>
|
|
<x-proxmox::ui.help-modal title="Resource Allocation">
|
|
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
|
VM resource consumption vs allocated entitlements.
|
|
</p>
|
|
<div class="pt-3 border-t border-gray-200 dark:border-gray-700 space-y-1.5 text-xs text-gray-600 dark:text-gray-400">
|
|
<p><span class="font-semibold text-gray-700 dark:text-gray-300">CPU:</span> Live usage % of vCPU entitlement.</p>
|
|
<p><span class="font-semibold text-gray-700 dark:text-gray-300">Memory:</span> RAM usage vs provisioned.</p>
|
|
<p><span class="font-semibold text-gray-700 dark:text-gray-300">Uptime:</span> Live uptime + power events.</p>
|
|
<p><span class="font-semibold text-gray-700 dark:text-gray-300">Badges:</span> Healthy (green), Watchlist (yellow 85%+), Over (red).</p>
|
|
</div>
|
|
</x-proxmox::ui.help-modal>
|
|
<span class="text-xs text-gray-500 dark:text-gray-400 ml-2 hidden md:inline">Live refresh</span>
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
@foreach($resourceAllocations as $resource)
|
|
<x-proxmox::ui.allocation-card
|
|
:label="$resource['label']"
|
|
:percent="$resource['used_percent']"
|
|
:usage-summary="$resource['usage_summary']"
|
|
:entitlement-label="$resource['entitlement_label']"
|
|
:description="$resource['description']"
|
|
:gradient="$resource['gradient']"
|
|
:badge="$resource['badge']"
|
|
/>
|
|
@endforeach
|
|
</div>
|
|
</x-proxmox::ui.card>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4 md:items-stretch">
|
|
|
|
<div class="grid gap-4 items-start">
|
|
<!-- Details -->
|
|
<div>
|
|
|
|
<div>
|
|
<div style="background-image: url('https://billing.virtnet.bond/assets/images/main.png'); background-size: cover; height: 3rem;" class="rounded-t-lg border-t border-x border-gray-200 dark:border-gray-700"></div>
|
|
|
|
<!-- Status Section -->
|
|
<div class="bg-white dark:bg-gray-800 border-x border-gray-200 dark:border-gray-700 p-3 space-y-3">
|
|
<!-- Status -->
|
|
<div class="flex items-center justify-between">
|
|
<label class="text-xs font-semibold text-gray-600 dark:text-gray-400">Status</label>
|
|
<div class="flex items-center gap-1.5 text-sm">
|
|
<div class="relative inline-flex size-4">
|
|
@if ($status && $status->status === 'running')
|
|
<span class="absolute inset-0 rounded-full bg-green-500 animate-ping opacity-75"></span>
|
|
<span class="relative flex items-center justify-center size-4 rounded-full text-green-600 bg-green-100 dark:bg-green-900">
|
|
<x-ri-circle-fill class="size-2" />
|
|
</span>
|
|
@else
|
|
<span class="flex items-center justify-center size-4 rounded-full text-amber-600 bg-amber-100 dark:bg-amber-900">
|
|
<x-ri-error-warning-fill class="size-2" />
|
|
</span>
|
|
@endif
|
|
</div>
|
|
<span class="font-medium text-gray-800 dark:text-gray-200">
|
|
{{ $status && $status->status === 'running' ? 'Running' : 'Stopped' }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Operating System -->
|
|
<div class="flex items-center justify-between">
|
|
<label class="text-xs font-semibold text-gray-600 dark:text-gray-400">OS</label>
|
|
<span class="text-sm text-gray-800 dark:text-gray-200">{{ $server->os->name }}</span>
|
|
</div>
|
|
|
|
<!-- Hostname -->
|
|
<div class="flex items-center justify-between" x-data="{ showHostnameEditModal: @entangle('isEditingHostname') }">
|
|
<label class="text-xs font-semibold text-gray-600 dark:text-gray-400">Hostname</label>
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-sm font-mono text-gray-800 dark:text-gray-200">{{ $server->hostname ?? $status->name ?? 'N/A' }}</span>
|
|
<button wire:click="startEditingHostname" type="button" class="text-gray-500 hover:text-gray-700 dark:text-gray-400">
|
|
<x-ri-edit-fill class="size-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Hostname Edit Modal -->
|
|
<div x-cloak x-show="showHostnameEditModal"
|
|
x-transition:enter="transition ease-out duration-200"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
x-transition:leave="transition ease-in duration-150"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
@click="showHostnameEditModal = false; $wire.cancelEditingHostname()"
|
|
@keydown.escape.window="showHostnameEditModal = false; $wire.cancelEditingHostname()"
|
|
class="fixed inset-0 z-[60] bg-gray-900 bg-opacity-50"
|
|
style="display: none;">
|
|
</div>
|
|
<div x-cloak x-show="showHostnameEditModal"
|
|
x-transition:enter="transition ease-out duration-200"
|
|
x-transition:enter-start="opacity-0 scale-95"
|
|
x-transition:enter-end="opacity-100 scale-100"
|
|
x-transition:leave="transition ease-in duration-150"
|
|
x-transition:leave-start="opacity-100 scale-100"
|
|
x-transition:leave-end="opacity-0 scale-95"
|
|
@click.away="showHostnameEditModal = false; $wire.cancelEditingHostname()"
|
|
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
|
style="display: none;">
|
|
<div class="relative bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl w-full max-w-md p-4 space-y-3">
|
|
<div class="flex items-center justify-between">
|
|
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Edit Hostname</h4>
|
|
<button type="button" class="text-gray-400 hover:text-gray-600 p-1"
|
|
@click="showHostnameEditModal = false; $wire.cancelEditingHostname()">
|
|
<x-ri-close-line class="size-4" />
|
|
</button>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<div>
|
|
<label class="text-xs font-medium text-gray-600 dark:text-gray-400">Hostname</label>
|
|
<input type="text" wire:model.defer="hostname" class="form-input w-full mt-1 text-sm" placeholder="Enter hostname" />
|
|
</div>
|
|
@if($hostnameError)
|
|
<div class="text-xs text-red-600">{{ $hostnameError }}</div>
|
|
@endif
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
<strong>Save:</strong> Database only. <strong>Save & Apply:</strong> + cloud-init on next boot.
|
|
</p>
|
|
</div>
|
|
<div class="flex justify-end gap-2">
|
|
<x-button.secondary type="button" @click="showHostnameEditModal = false; $wire.cancelEditingHostname()">
|
|
Cancel
|
|
</x-button.secondary>
|
|
<x-button.primary type="button" wire:click="updateHostname">
|
|
Save
|
|
</x-button.primary>
|
|
<x-button.primary type="button" wire:click="applyHostname">
|
|
Save & Apply
|
|
</x-button.primary>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Primary IPv4 Address -->
|
|
@if($server->primaryIpv4)
|
|
<div class="flex items-center justify-between" x-data="{ copied: false, copyText: '{{ $server->primaryIpv4->ip }}', copy() { navigator.clipboard.writeText(this.copyText); this.copied = true; setTimeout(() => this.copied = false, 2000); } }">
|
|
<label class="text-xs font-semibold text-gray-600 dark:text-gray-400">IPv4</label>
|
|
<div class="flex items-center gap-1.5">
|
|
<span class="text-sm font-mono text-gray-800 dark:text-gray-200">{{ $server->primaryIpv4->ip }}</span>
|
|
<button @click="copy()" type="button" class="text-gray-500 hover:text-gray-700 dark:text-gray-400">
|
|
<x-ri-check-fill x-show="copied" class="size-3.5 text-green-600" />
|
|
<x-ri-file-copy-fill x-show="!copied" class="size-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
<!-- Primary IPv6 Address -->
|
|
@if($server->primaryIpv6)
|
|
<div class="flex items-center justify-between" x-data="{ copied: false, copyText: '{{ $server->primaryIpv6->ip }}', copy() { navigator.clipboard.writeText(this.copyText); this.copied = true; setTimeout(() => this.copied = false, 2000); } }">
|
|
<label class="text-xs font-semibold text-gray-600 dark:text-gray-400">IPv6</label>
|
|
<div class="flex items-center gap-1.5">
|
|
<span class="text-xs font-mono text-gray-800 dark:text-gray-200 truncate max-w-[180px]">{{ $server->primaryIpv6->ip }}</span>
|
|
<button @click="copy()" type="button" class="text-gray-500 hover:text-gray-700 dark:text-gray-400">
|
|
<x-ri-check-fill x-show="copied" class="size-3.5 text-green-600" />
|
|
<x-ri-file-copy-fill x-show="!copied" class="size-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
<!-- Root Password -->
|
|
<div class="flex items-center justify-between" x-data="{ showPasswordViewModal: false, showPasswordEditModal: @entangle('isEditingPassword') }">
|
|
<label class="text-xs font-semibold text-gray-600 dark:text-gray-400">Password</label>
|
|
<div class="flex items-center gap-1.5">
|
|
<span class="text-sm text-gray-800 dark:text-gray-200">
|
|
@if(isset($server->password) && $server->password)
|
|
••••••••
|
|
@else
|
|
Emailed
|
|
@endif
|
|
</span>
|
|
@if(isset($server->password) && $server->password)
|
|
<button type="button" class="text-gray-500 hover:text-gray-700 dark:text-gray-400" @click="showPasswordViewModal = true">
|
|
<x-ri-eye-line class="size-3.5" />
|
|
</button>
|
|
@endif
|
|
<button type="button" class="text-gray-500 hover:text-gray-700 dark:text-gray-400" wire:click="startEditingPassword">
|
|
<x-ri-edit-fill class="size-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Password View Modal -->
|
|
<div x-cloak x-show="showPasswordViewModal"
|
|
x-transition:enter="transition ease-out duration-200"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
x-transition:leave="transition ease-in duration-150"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
@click="showPasswordViewModal = false"
|
|
@keydown.escape.window="showPasswordViewModal = false"
|
|
class="fixed inset-0 z-[60] bg-gray-900 bg-opacity-50"
|
|
style="display: none;">
|
|
</div>
|
|
<div x-cloak x-show="showPasswordViewModal"
|
|
x-transition:enter="transition ease-out duration-200"
|
|
x-transition:enter-start="opacity-0 scale-95"
|
|
x-transition:enter-end="opacity-100 scale-100"
|
|
x-transition:leave="transition ease-in duration-150"
|
|
x-transition:leave-start="opacity-100 scale-100"
|
|
x-transition:leave-end="opacity-0 scale-95"
|
|
@click.away="showPasswordViewModal = false"
|
|
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
|
style="display: none;"
|
|
x-data="{
|
|
copied: false,
|
|
copyText: {{ json_encode($server->password) }},
|
|
copy() { navigator.clipboard.writeText(this.copyText); this.copied = true; setTimeout(() => this.copied = false, 2000); }
|
|
}">
|
|
<div class="relative bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl w-full max-w-md p-4 space-y-3">
|
|
<div class="flex items-center justify-between">
|
|
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Root Password</h4>
|
|
<button type="button" class="text-gray-400 hover:text-gray-600 p-1" @click="showPasswordViewModal = false">
|
|
<x-ri-close-line class="size-4" />
|
|
</button>
|
|
</div>
|
|
<div class="bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded p-2 text-sm text-gray-800 dark:text-gray-200 overflow-auto">
|
|
<code class="break-all block font-mono text-xs">{{ $server->password }}</code>
|
|
</div>
|
|
<div class="flex justify-end">
|
|
<button type="button" class="px-3 py-1.5 rounded bg-blue-600 text-white text-xs font-medium hover:bg-blue-700" @click="copy()">
|
|
<span x-show="!copied">Copy</span>
|
|
<span x-show="copied">Copied!</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Password Edit Modal -->
|
|
<div x-cloak x-show="showPasswordEditModal"
|
|
x-transition:enter="transition ease-out duration-200"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
x-transition:leave="transition ease-in duration-150"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
@click="showPasswordEditModal = false; $wire.cancelEditingPassword()"
|
|
@keydown.escape.window="showPasswordEditModal = false; $wire.cancelEditingPassword()"
|
|
class="fixed inset-0 z-[60] bg-gray-900 bg-opacity-50"
|
|
style="display: none;">
|
|
</div>
|
|
<div x-cloak x-show="showPasswordEditModal"
|
|
x-transition:enter="transition ease-out duration-200"
|
|
x-transition:enter-start="opacity-0 scale-95"
|
|
x-transition:enter-end="opacity-100 scale-100"
|
|
x-transition:leave="transition ease-in duration-150"
|
|
x-transition:leave-start="opacity-100 scale-100"
|
|
x-transition:leave-end="opacity-0 scale-95"
|
|
@click.away="showPasswordEditModal = false; $wire.cancelEditingPassword()"
|
|
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
|
style="display: none;">
|
|
<div class="relative bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl w-full max-w-md p-4 space-y-3">
|
|
<div class="flex items-center justify-between">
|
|
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Edit Root Password</h4>
|
|
<button type="button" class="text-gray-400 hover:text-gray-600 p-1"
|
|
@click="showPasswordEditModal = false; $wire.cancelEditingPassword()">
|
|
<x-ri-close-line class="size-4" />
|
|
</button>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<div>
|
|
<label class="text-xs font-medium text-gray-600 dark:text-gray-400">New Password</label>
|
|
<input type="password" wire:model.defer="password" class="form-input w-full mt-1 text-sm" placeholder="Enter new password" />
|
|
</div>
|
|
<div>
|
|
<label class="text-xs font-medium text-gray-600 dark:text-gray-400">Confirm Password</label>
|
|
<input type="password"
|
|
wire:model.defer="password_confirmation"
|
|
class="form-input w-full mt-1 text-sm"
|
|
placeholder="Confirm password" />
|
|
</div>
|
|
@if($passwordError)
|
|
<div class="text-xs text-red-600">{{ $passwordError }}</div>
|
|
@endif
|
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
<strong>Save:</strong> Database only. <strong>Save & Apply:</strong> + cloud-init (reboot required).
|
|
</p>
|
|
</div>
|
|
<div class="flex justify-end gap-2">
|
|
<x-button.secondary type="button" @click="showPasswordEditModal = false; $wire.cancelEditingPassword()">
|
|
Cancel
|
|
</x-button.secondary>
|
|
<x-button.primary type="button" wire:click="updatePassword">
|
|
Save
|
|
</x-button.primary>
|
|
<x-button.primary type="button" wire:click="applyPassword">
|
|
Save & Apply
|
|
</x-button.primary>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between bg-red-50 dark:bg-red-950 border-t border-x border-red-200 dark:border-red-800 px-3 py-2 rounded-b-lg">
|
|
<div>
|
|
<span class="text-sm font-semibold text-red-700 dark:text-red-300">Reinstall Server</span>
|
|
<p class="text-xs text-red-600 dark:text-red-400">Data permanently deleted. Irreversible.</p>
|
|
</div>
|
|
<livewire:proxmox.reinstall :server="$server" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div style="background-image: url('https://billing.virtnet.bond/assets/images/dns.jpeg'); background-size: cover; background-position: center; height: 3rem;" class="rounded-t-lg border-t border-x border-gray-200 dark:border-gray-700"></div>
|
|
<div class="bg-white dark:bg-gray-800 border-x border-gray-200 dark:border-gray-700 p-3 space-y-3">
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
<h2 class="text-base font-semibold">DNS</h2>
|
|
<div class="relative" x-data="{ open: false }" x-cloak>
|
|
<button type="button" @click="open = true" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
|
<x-ri-question-line class="size-4" />
|
|
</button>
|
|
<div x-show="open" @click="open = false" @keydown.escape.window="open = false"
|
|
class="fixed inset-0 z-[60] bg-gray-900 bg-opacity-50" style="display: none;"></div>
|
|
<div x-show="open" @click.away="open = false"
|
|
class="fixed inset-0 z-[60] flex items-center justify-center p-4" style="display: none;">
|
|
<div class="relative w-full max-w-xl max-h-[90vh] overflow-y-auto bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl p-4">
|
|
<button @click="open = false" class="absolute top-3 right-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
|
<x-ri-close-line class="size-4" />
|
|
</button>
|
|
<div class="flex items-center gap-2 mb-3 pr-6">
|
|
<div class="flex items-center justify-center w-8 h-8 rounded bg-blue-50 dark:bg-blue-900">
|
|
<x-ri-global-line class="size-4 text-primary" />
|
|
</div>
|
|
<h6 class="text-base font-semibold">Domain Name System (DNS)</h6>
|
|
</div>
|
|
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
|
DNS info for your VM. Displays hostname and DNS configuration for the guest OS.
|
|
</p>
|
|
<div class="pt-3 border-t border-gray-200 dark:border-gray-700">
|
|
<p class="text-xs text-gray-600 dark:text-gray-400">
|
|
<strong>Note:</strong> DNS configured via cloud-init. For Windows/manual, configure within guest OS.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
|
<p class="font-medium">IPv6 DNS: {{ $server->primaryIpv6->ip }}</p>
|
|
|
|
<!-- Virtnet -->
|
|
<div class="flex items-center gap-1 ml-4 text-xs">
|
|
<span>{{ str_replace(':', '-', $server->primaryIpv6->ip) . '.virtnet.host' }} <small class="text-gray-500">(Internal)</small></span>
|
|
<div x-data="{
|
|
copied: false,
|
|
copyText: '{{ str_replace(':', '-', $server->primaryIpv6->ip) . '.virtnet.host' }}',
|
|
copyToClipboard() { navigator.clipboard.writeText(this.copyText); this.copied = true; setTimeout(() => this.copied = false, 2000); }
|
|
}">
|
|
<button @click="copyToClipboard()" type="button" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 relative inline-flex items-center justify-center">
|
|
<span x-show="copied" x-transition class="absolute"><x-ri-check-fill class="size-3 text-success" /></span>
|
|
<span x-show="!copied" x-transition class="absolute"><x-ri-file-copy-fill class="size-3" /></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- nip.io -->
|
|
<div class="flex items-center gap-1 ml-4 text-xs">
|
|
<span>{{ str_replace(':', '-', $server->primaryIpv6->ip) . '.nip.io' }} <small class="text-gray-500">(<a href="https://nip.io" target="_blank" class="underline">nip.io</a>)</small></span>
|
|
<div x-data="{
|
|
copied: false,
|
|
copyText: '{{ str_replace(':', '-', $server->primaryIpv6->ip) . '.nip.io' }}',
|
|
copyToClipboard() { navigator.clipboard.writeText(this.copyText); this.copied = true; setTimeout(() => this.copied = false, 2000); }
|
|
}">
|
|
<button @click="copyToClipboard()" type="button" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 relative inline-flex items-center justify-center">
|
|
<span x-show="copied" x-transition class="absolute"><x-ri-check-fill class="size-3 text-success" /></span>
|
|
<span x-show="!copied" x-transition class="absolute"><x-ri-file-copy-fill class="size-3" /></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- sslip.io -->
|
|
<div class="flex items-center gap-1 ml-4 text-xs">
|
|
<span>{{ str_replace(':', '-', $server->primaryIpv6->ip) . '.sslip.io' }} <small class="text-gray-500">(<a href="https://sslip.io" target="_blank" class="underline">sslip.io</a>)</small></span>
|
|
<div x-data="{
|
|
copied: false,
|
|
copyText: '{{ str_replace(':', '-', $server->primaryIpv6->ip) . '.sslip.io' }}',
|
|
copyToClipboard() { navigator.clipboard.writeText(this.copyText); this.copied = true; setTimeout(() => this.copied = false, 2000); }
|
|
}">
|
|
<button @click="copyToClipboard()" type="button" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 relative inline-flex items-center justify-center">
|
|
<span x-show="copied" x-transition class="absolute"><x-ri-check-fill class="size-3 text-success" /></span>
|
|
<span x-show="!copied" x-transition class="absolute"><x-ri-file-copy-fill class="size-3" /></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- backname.io -->
|
|
<div class="flex items-center gap-1 ml-4 text-xs">
|
|
<span>{{ str_replace(':', '-', $server->primaryIpv6->ip) . '.backname.io' }} <small class="text-gray-500">(<a href="https://backname.io" target="_blank" class="underline">backname.io</a>)</small></span>
|
|
<div x-data="{
|
|
copied: false,
|
|
copyText: '{{ str_replace(':', '-', $server->primaryIpv6->ip) . '.backname.io' }}',
|
|
copyToClipboard() { navigator.clipboard.writeText(this.copyText); this.copied = true; setTimeout(() => this.copied = false, 2000); }
|
|
}">
|
|
<button @click="copyToClipboard()" type="button" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 relative inline-flex items-center justify-center">
|
|
<span x-show="copied" x-transition class="absolute"><x-ri-check-fill class="size-3 text-success" /></span>
|
|
<span x-show="!copied" x-transition class="absolute"><x-ri-file-copy-fill class="size-3" /></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 p-3 rounded-b-lg text-xs text-gray-600 dark:text-gray-400">
|
|
<p class="font-semibold"><code>@</code> = root domain → {{ $server->primaryIpv6->ip }} | <code>*.@</code> = wildcard subdomains → same IP</p>
|
|
<p class="mt-1 text-gray-500 dark:text-gray-500">rDNS PTR: No PTR records via RIR systems except direct contract customers.</p>
|
|
</div>
|
|
</div>
|
|
|
|
@if(stripos($server->os->name, 'Windows') !== false)
|
|
<div>
|
|
<div style="background-image: url('https://billing.virtnet.bond/assets/images/windows.jpg'); background-size: cover; background-position: center; height: 3rem;" class="rounded-t-lg border-t border-x border-gray-200 dark:border-gray-700"></div>
|
|
<div class="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 p-3 rounded-b-lg">
|
|
<div class="flex items-center gap-2">
|
|
<h2 class="text-base font-semibold">Windows Server</h2>
|
|
<div class="relative" x-data="{ open: false }" x-cloak>
|
|
<button type="button" @click="open = true" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
|
<x-ri-question-line class="size-4" />
|
|
</button>
|
|
<div x-show="open" @click="open = false" @keydown.escape.window="open = false"
|
|
class="fixed inset-0 z-[60] bg-gray-900 bg-opacity-50 backdrop-blur-sm" style="display: none;"></div>
|
|
<div x-show="open" @click.away="open = false"
|
|
class="fixed inset-0 z-[60] flex items-center justify-center p-4" style="display: none;">
|
|
<div class="relative w-full max-w-xl max-h-[90vh] overflow-y-auto bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl p-4">
|
|
<button @click="open = false" class="absolute top-3 right-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
|
<x-ri-close-line class="size-4" />
|
|
</button>
|
|
<div class="flex items-center gap-2 mb-3 pr-6">
|
|
<div class="flex items-center justify-center w-8 h-8 rounded bg-blue-50 dark:bg-blue-900">
|
|
<x-ri-windows-line class="size-4 text-primary" />
|
|
</div>
|
|
<h6 class="text-base font-semibold">Windows Server</h6>
|
|
</div>
|
|
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">Windows doesn't support cloud-init. Manual config required.</p>
|
|
<div class="pt-3 border-t border-gray-200 dark:border-gray-700 space-y-2 text-xs text-gray-600 dark:text-gray-400">
|
|
<p><strong>Manual Config:</strong> Configure networking, hostname, and users during/after install.</p>
|
|
<p><strong>Network:</strong> Use static IPs or DHCP via Windows Network Configuration.</p>
|
|
<p><strong>Remote Access:</strong> Enable RDP within Windows for remote server access.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-2 space-y-2 text-xs">
|
|
<div class="border-l-4 border-blue-400 bg-blue-100 dark:bg-blue-900 p-2 rounded-r">
|
|
<p class="font-semibold text-gray-700 dark:text-gray-200">IPv4/IPv6 Setup:</p>
|
|
<p class="text-gray-600 dark:text-gray-400">Cloud-init doesn't configure networking on Windows. Configure addresses manually.</p>
|
|
</div>
|
|
<div class="border-l-4 border-blue-400 bg-blue-100 dark:bg-blue-900 p-2 rounded-r">
|
|
<p class="font-semibold text-gray-700 dark:text-gray-200">Licensing:</p>
|
|
<p class="text-gray-600 dark:text-gray-400">No license key provided. User must maintain compliant licensing.</p>
|
|
</div>
|
|
<div class="border-l-4 border-blue-400 bg-blue-100 dark:bg-blue-900 p-2 rounded-r">
|
|
<p class="font-semibold text-gray-700 dark:text-gray-200">Audits:</p>
|
|
<p class="text-gray-600 dark:text-gray-400">Periodic audits may occur. Non-compliant installations subject to suspension.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
|
|
<!-- Notes Section -->
|
|
<div class="mt-4">
|
|
<div id="notes-section" class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 flex flex-col" x-data="{ showExportErrorModal: false }" x-cloak>
|
|
<div class="flex items-center justify-between mb-3">
|
|
<div class="flex items-center gap-2">
|
|
<h2 class="text-base font-semibold">Notes</h2>
|
|
<div class="relative" x-data="{ open: false }" x-cloak>
|
|
<button type="button" @click="open = true" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
|
<x-ri-question-line class="size-4" />
|
|
</button>
|
|
<div x-show="open" @click="open = false" @keydown.escape.window="open = false"
|
|
class="fixed inset-0 z-[60] bg-gray-900 bg-opacity-50 backdrop-blur-sm" style="display: none;"></div>
|
|
<div x-show="open" @click.away="open = false" @keydown.escape.window="open = false"
|
|
class="fixed inset-0 z-[60] flex items-center justify-center p-4" style="display: none;">
|
|
<div class="relative w-full max-w-xl max-h-[90vh] overflow-y-auto bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl p-4">
|
|
<button @click="open = false" class="absolute top-3 right-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
|
<x-ri-close-line class="size-4" />
|
|
</button>
|
|
<div class="flex items-center gap-2 mb-3 pr-6">
|
|
<div class="flex items-center justify-center w-8 h-8 rounded bg-blue-50 dark:bg-blue-900">
|
|
<x-ri-file-text-line class="size-4 text-primary" />
|
|
</div>
|
|
<h6 class="text-base font-semibold">Notes</h6>
|
|
</div>
|
|
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">Store notes about this VM with Markdown support.</p>
|
|
<div class="pt-3 border-t border-gray-200 dark:border-gray-700 space-y-2 text-xs text-gray-600 dark:text-gray-400">
|
|
<p><strong>Markdown:</strong> Headers, lists, links, code blocks supported.</p>
|
|
<p><strong>Persistence:</strong> Notes saved to DB but deleted on reinstall.</p>
|
|
<p><strong>Uses:</strong> Config docs, IPs, credentials, install steps.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">(Markdown • Cleared on reinstall)</span>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<button type="button" @click="exportNotesToCSV()"
|
|
class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-2 py-1 rounded text-xs font-semibold hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center gap-1"
|
|
title="Export notes">
|
|
<x-ri-download-line class="size-3" />
|
|
Export
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Export Error Modal -->
|
|
<div x-show="showExportErrorModal"
|
|
x-transition:enter="transition ease-out duration-300"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
x-transition:leave="transition ease-in duration-200"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
@click="showExportErrorModal = false"
|
|
@keydown.escape.window="showExportErrorModal = false"
|
|
class="fixed inset-0 z-[60] bg-gray-900 bg-opacity-50 backdrop-blur-sm"
|
|
style="display: none;">
|
|
</div>
|
|
<div x-show="showExportErrorModal" @click.away="showExportErrorModal = false"
|
|
class="fixed inset-0 z-[60] flex items-center justify-center p-4" style="display: none;">
|
|
<div class="relative w-full max-w-sm bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl p-4">
|
|
<button @click="showExportErrorModal = false" class="absolute top-3 right-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
|
<x-ri-close-line class="size-4" />
|
|
</button>
|
|
<div class="flex items-center gap-2 mb-3 pr-6">
|
|
<div class="flex items-center justify-center w-8 h-8 rounded bg-red-50 dark:bg-red-900">
|
|
<x-ri-error-warning-line class="size-4 text-error" />
|
|
</div>
|
|
<h6 class="text-base font-semibold">Export Error</h6>
|
|
</div>
|
|
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">No notes available to export.</p>
|
|
<div class="flex justify-end">
|
|
<x-button.secondary type="button" @click="showExportErrorModal = false">Close</x-button.secondary>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2 flex-1">
|
|
@if($isEditingNotes)
|
|
<div class="space-y-2 flex-1 flex flex-col">
|
|
<textarea wire:model="notes"
|
|
class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded px-2 py-1 text-sm w-full resize-y flex-1 focus:outline-none focus:ring-2 focus:ring-secondary"
|
|
placeholder="Enter notes..."></textarea>
|
|
@if($notesError)
|
|
<div class="text-xs text-error">{{ $notesError }}</div>
|
|
@endif
|
|
<div class="flex gap-2">
|
|
<x-button.primary wire:click="saveNotes" wire:loading.attr="disabled">
|
|
<x-loading target="saveNotes" />
|
|
<div wire:loading.remove wire:target="saveNotes">Save</div>
|
|
<div wire:loading wire:target="saveNotes">Saving...</div>
|
|
</x-button.primary>
|
|
<x-button.secondary wire:click="cancelEditingNotes" wire:loading.attr="disabled">Cancel</x-button.secondary>
|
|
</div>
|
|
</div>
|
|
@else
|
|
<div class="space-y-2 flex-1 flex flex-col">
|
|
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded px-2 py-1 text-sm flex-1 overflow-auto prose prose-sm dark:prose-invert max-w-none text-gray-700 dark:text-gray-300">
|
|
@if($notes)
|
|
{!! \Illuminate\Support\Str::markdown(e($notes), [
|
|
'allow_unsafe_links' => false,
|
|
'renderer' => ['soft_break' => "<br>"]
|
|
]) !!}
|
|
@else
|
|
<p class="text-gray-500 dark:text-gray-400">No notes. Click Edit to add.</p>
|
|
@endif
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<x-button.secondary wire:click="startEditingNotes">
|
|
<x-ri-edit-fill class="size-3" /> Edit
|
|
</x-button.secondary>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
@once
|
|
<script>
|
|
// Export helper function for downloading text files
|
|
window.downloadText = function(textContent, filename) {
|
|
const blob = new Blob([textContent], { type: 'text/plain;charset=utf-8;' });
|
|
const link = document.createElement('a');
|
|
const url = URL.createObjectURL(blob);
|
|
link.setAttribute('href', url);
|
|
link.setAttribute('download', filename);
|
|
link.style.visibility = 'hidden';
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
};
|
|
|
|
// Export Notes to text file
|
|
window.exportNotesToCSV = function() {
|
|
let notesText = '';
|
|
|
|
// Try to get notes from Livewire component
|
|
const livewireComponent = Livewire.find(document.querySelector('[wire\\:id]')?.getAttribute('wire:id') ||
|
|
document.querySelector('[wire\\:model="notes"]')?.closest('[wire\\:id]')?.getAttribute('wire:id'));
|
|
|
|
if (livewireComponent && livewireComponent.get('notes')) {
|
|
notesText = livewireComponent.get('notes') || '';
|
|
} else {
|
|
// Fallback: try to get from textarea
|
|
const notesTextarea = document.querySelector('[wire\\:model="notes"]');
|
|
if (notesTextarea && notesTextarea.tagName === 'TEXTAREA') {
|
|
notesText = notesTextarea.value || '';
|
|
} else {
|
|
// Fallback: extract from rendered markdown (less ideal, but works)
|
|
const notesElement = document.querySelector('.prose');
|
|
if (notesElement) {
|
|
notesText = notesElement.innerText || notesElement.textContent || '';
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!notesText || notesText.trim() === '' || notesText.includes('No notes stored')) {
|
|
// Find the notes section and show the error modal
|
|
const notesSection = document.getElementById('notes-section');
|
|
if (notesSection && notesSection._x_dataStack && notesSection._x_dataStack[0]) {
|
|
notesSection._x_dataStack[0].showExportErrorModal = true;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const filename = 'notes-' + new Date().toISOString().split('T')[0] + '.txt';
|
|
window.downloadText(notesText, filename);
|
|
};
|
|
</script>
|
|
@endonce
|
|
@endif
|
|
|
|
</x-proxmox::layout>
|