Files
Virtnet.bond/themes/lucentui/views/vendor/pterodactyl2/startup.blade.php
2025-11-30 11:57:22 +01:00

366 lines
16 KiB
PHP

<section id="startup-section"
class="relative flex flex-col h-full w-full
p-6 rounded-xl mx-auto
overflow-hidden">
{{-- spinner overlay while saving startup params --}}
<div id="startup-spinner"
class="fixed inset-0 bg-gray-900/25 z-20 hidden">
<div class="w-12 h-12 border-4 border-t-blue-500 border-gray-600 rounded-full animate-spin"></div>
</div>
<h2 class="text-3xl font-semibold mb-4">Startup Parameters</h2>
{{-- show current compiled startup command --}}
<input id="startup-command"
type="text"
disabled
class="w-full mb-6 px-4 py-2 bg-gray-800/25
text-base rounded-xl font-mono text-lg"
value="Loading command…" />
<form id="startup-form" class="rounded-xl space-y-6">
{{-- dynamic inputs will be injected here --}}
<div id="startup-fields" class="grid grid-cols-1 md:grid-cols-2 gap-6"></div>
<div class="flex justify-end pt-4">
<button type="submit" class="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition">
Save Changes
</button>
</div>
</form>
</section>
{{-- toast container (moved outside of section to always exist) --}}
<div id="toast-container" class="fixed top-4 right-4 space-y-2 z-50 pointer-events-none"></div>
@script
<script type="module">
(async () => {
// --- Toast Helper ---
function getToastContainer() {
let container = document.getElementById('toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'toast-container';
container.className = 'fixed top-4 right-4 space-y-2 z-50 pointer-events-none';
document.body.appendChild(container);
}
return container;
}
function showToast(message, type = 'info') {
const container = getToastContainer();
const toast = document.createElement('div');
// Determine colors and icons
let borderColor, iconSvg;
if (type === 'success') {
borderColor = 'border-green-500';
iconSvg = '<svg class="w-6 h-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>';
} else if (type === 'error') {
borderColor = 'border-red-500';
iconSvg = '<svg class="w-6 h-6 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>';
} else {
borderColor = 'border-yellow-500';
iconSvg = '<svg class="w-6 h-6 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M12 17a5 5 0 100-10 5 5 0 000 10z"/></svg>';
}
toast.className = `flex items-start gap-3 bg-white dark:bg-gray-800 border-l-4 ${borderColor} shadow-lg rounded-xl p-4 pointer-events-auto transition-opacity duration-500`;
toast.innerHTML = `
<div class="flex-shrink-0">${iconSvg}</div>
<div class="flex-1 text-sm text-gray-900 dark:text-gray-100">${message}</div>
`;
container.appendChild(toast);
// Fade out and remove after 3.5s
setTimeout(() => {
toast.classList.add('opacity-0');
setTimeout(() => toast.remove(), 500);
}, 3000);
}
// --- API Client ---
class ApiClient {
constructor(serviceId) {
this.serviceId = serviceId;
this.baseUrl = `/services/${serviceId}/startup`;
}
async request(endpoint = '', method = 'GET', body = null) {
const spinner = document.getElementById('startup-spinner');
if (spinner) spinner.classList.remove('hidden');
try {
const headers = {
'X-CSRF-TOKEN': document
.querySelector('meta[name="csrf-token"]')
.getAttribute('content'),
'Accept': 'application/json',
'Content-Type': 'application/json',
};
const resp = await fetch(this.baseUrl + endpoint, {
method, headers, body
});
if (!resp.ok) {
const error = await resp.json();
throw new Error(error.error || `HTTP ${resp.status}`);
}
return await resp.json();
} finally {
if (spinner) spinner.classList.add('hidden');
}
}
listStartup() {
return this.request();
}
updateStartup(param) {
// param = { key, value }
return this.request('', 'POST', JSON.stringify(param));
}
}
// --- Mount / Unmount System with Event Listener Tracking ---
let isMounted = false;
let mounting = false;
let eventListeners = [];
// Helper function to add tracked event listeners
function addTrackedEventListener(element, type, listener) {
if (!element) return;
element.addEventListener(type, listener);
eventListeners.push({ element, type, listener });
}
// Function to check if elements are already initialized
function elementsAlreadyInitialized() {
const container = document.getElementById('startup-fields');
return container && container.hasAttribute('data-startup-initialized');
}
// Function to cleanup existing elements
function cleanupExistingElements() {
console.log('Cleaning up existing startup elements...');
// Clean up event listeners
eventListeners.forEach(({ element, type, listener }) => {
try {
if (element && typeof element.removeEventListener === 'function') {
element.removeEventListener(type, listener);
}
} catch (e) {
console.warn('Error removing event listener:', e);
}
});
eventListeners = [];
// Clean up containers
const fieldsEl = document.getElementById('startup-fields');
if (fieldsEl) {
fieldsEl.innerHTML = '';
fieldsEl.removeAttribute('data-startup-initialized');
}
// Reset command display
const cmdEl = document.getElementById('startup-command');
if (cmdEl) cmdEl.value = 'Loading command…';
}
async function mountStartup() {
if (isMounted || mounting) {
console.log('Mount startup called but already mounted/mounting:', { isMounted, mounting });
return;
}
// Check if elements are already initialized and clean them up
if (elementsAlreadyInitialized()) {
console.log('Startup elements already initialized, cleaning up before mounting...');
cleanupExistingElements();
await new Promise(resolve => setTimeout(resolve, 100));
}
console.log('Starting startup mount...');
mounting = true;
try {
const api = new ApiClient({{ $service->id }});
const fieldsEl = document.getElementById('startup-fields');
const formEl = document.getElementById('startup-form');
const spinner = document.getElementById('startup-spinner');
if (!fieldsEl || !formEl) {
throw new Error('Required startup elements not found');
}
// Mark container as initialized
fieldsEl.setAttribute('data-startup-initialized', 'true');
const originalValues = {};
// Load and render form fields
async function loadParams() {
try {
const resp = await api.listStartup();
// Display the compiled startup_command in the disabled input
const cmdEl = document.getElementById('startup-command');
if (cmdEl) {
cmdEl.value = resp.meta?.startup_command || '—';
}
fieldsEl.innerHTML = '';
const data = resp.data || [];
if (data.length === 0) {
fieldsEl.innerHTML = `
<div class="col-span-full flex flex-col items-center justify-center py-12 text-base/50">
<svg class="w-16 h-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<h3 class="text-lg font-bold mb-2">No startup parameters found</h3>
<p class="text-sm">This server doesn't have configurable startup parameters.</p>
</div>
`;
return;
}
data.forEach(item => {
const { env_variable, name, description, server_value, is_editable } = item.attributes;
originalValues[env_variable] = server_value;
const card = document.createElement('div');
card.className = 'flex flex-col bg-gray-900/25 p-4 rounded-xl border border-neutral';
const label = document.createElement('label');
label.htmlFor = env_variable;
label.className = 'text-gray-700 dark:text-base/40 font-medium mb-2';
label.textContent = name;
const input = document.createElement('input');
input.id = env_variable;
input.name = env_variable;
input.type = 'text';
input.value = server_value || '';
input.disabled = !is_editable;
input.className = `mt-1 px-3 py-2 bg-gray-100 dark:bg-gray-800/25 text-gray-900 dark:text-white rounded-xl placeholder-gray-500 dark:placeholder-gray-400 border border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 transition ${!is_editable ? 'opacity-50 cursor-not-allowed' : ''}`;
const hint = document.createElement('p');
hint.className = 'text-xs text-gray-500 mt-2 italic';
hint.textContent = description || 'No description available';
card.append(label, input, hint);
fieldsEl.appendChild(card);
});
} catch (error) {
console.error('Failed to load startup parameters:', error);
showToast('Failed to load startup parameters', 'error');
fieldsEl.innerHTML = `
<div class="col-span-full flex flex-col items-center justify-center py-12 text-red-400">
<svg class="w-16 h-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<h3 class="text-lg font-medium mb-2">Failed to load parameters</h3>
<p class="text-sm">Please try refreshing the page.</p>
</div>
`;
}
}
// Handle form submission with tracking
addTrackedEventListener(formEl, 'submit', async (e) => {
e.preventDefault();
if (!isMounted) return;
if (spinner) spinner.classList.remove('hidden');
try {
const inputs = Array.from(fieldsEl.querySelectorAll('input:not([disabled])'));
const updates = inputs
.filter(inp => inp.value !== originalValues[inp.name])
.map(inp => ({ key: inp.name, value: inp.value }));
if (updates.length === 0) {
showToast('No changes to save', 'info');
return;
}
let successCount = 0;
let errorCount = 0;
for (const upd of updates) {
try {
await api.updateStartup(upd);
originalValues[upd.key] = upd.value;
successCount++;
} catch (error) {
console.error('Save error for parameter:', upd.key, error);
showToast(`Failed to save ${upd.key}: ${error.message}`, 'error');
errorCount++;
}
}
if (successCount > 0) {
showToast(`Successfully updated ${successCount} parameter${successCount !== 1 ? 's' : ''}`, 'success');
}
if (errorCount === 0) {
// Reload parameters to get updated command
await loadParams();
}
} catch (error) {
console.error('Form submission error:', error);
showToast('Failed to save changes', 'error');
} finally {
if (spinner) spinner.classList.add('hidden');
}
});
// Set mounted flag and load initial data
isMounted = true;
await loadParams();
console.log('Startup mounted successfully');
} catch (error) {
console.error('Failed to mount startup:', error);
showToast('Failed to initialize startup parameters', 'error');
} finally {
mounting = false;
}
}
function unmountStartup() {
if (!isMounted) {
console.log('Unmount startup called but not mounted');
return;
}
console.log('Unmounting startup...');
isMounted = false;
try {
// Clean up existing elements
cleanupExistingElements();
console.log('Startup unmounted successfully');
} catch (error) {
console.error('Error during startup unmount:', error);
}
}
function detectStartup() {
const exists = Boolean(document.getElementById('startup-section'));
if (exists && !isMounted) {
mountStartup();
}
if (!exists && isMounted) {
unmountStartup();
}
}
// Initialize
detectStartup();
setInterval(detectStartup, 1000);
})();
</script>
@endscript