Deploy Ladill Servers / deploy (push) Successful in 29s
Add shared confirm-dialog component, Alpine ladillConfirm store, and swap browser confirms for consistent bottom-sheet modals across user, admin, and hosting flows. Co-authored-by: Cursor <cursoragent@cursor.com>
558 lines
18 KiB
JavaScript
558 lines
18 KiB
JavaScript
import './bootstrap';
|
|
|
|
import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals';
|
|
|
|
registerLadillModalHelpers();
|
|
|
|
|
|
import Alpine from 'alpinejs';
|
|
import collapse from '@alpinejs/collapse';
|
|
|
|
Alpine.plugin(collapse);
|
|
|
|
Alpine.data('notificationDropdown', (config = {}) => ({
|
|
open: false,
|
|
loading: false,
|
|
notifications: [],
|
|
unreadCount: 0,
|
|
unreadUrl: config.unreadUrl || '/notifications/unread',
|
|
markReadUrl: config.markReadUrl || '/notifications/__ID__/read',
|
|
markAllReadUrl: config.markAllReadUrl || '/notifications/mark-all-read',
|
|
indexUrl: config.indexUrl || '/notifications',
|
|
csrfToken: config.csrfToken || document.querySelector('meta[name="csrf-token"]')?.content || '',
|
|
|
|
init() {
|
|
this.fetchUnread();
|
|
setInterval(() => this.fetchUnread(), 60000);
|
|
},
|
|
|
|
async fetchUnread() {
|
|
try {
|
|
const res = await fetch(this.unreadUrl, {
|
|
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
|
});
|
|
const data = await res.json();
|
|
this.notifications = data.notifications || [];
|
|
this.unreadCount = data.unread_count || 0;
|
|
} catch (e) {
|
|
console.error('Failed to fetch notifications', e);
|
|
}
|
|
},
|
|
|
|
toggle() {
|
|
this.open = !this.open;
|
|
if (this.open) {
|
|
this.loading = true;
|
|
this.fetchUnread().finally(() => { this.loading = false; });
|
|
}
|
|
},
|
|
|
|
async markRead(id) {
|
|
const url = this.markReadUrl.replace('__ID__', id);
|
|
try {
|
|
await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': this.csrfToken,
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
});
|
|
this.notifications = this.notifications.filter(n => n.id !== id);
|
|
this.unreadCount = Math.max(0, this.unreadCount - 1);
|
|
} catch (e) {
|
|
console.error('Failed to mark notification as read', e);
|
|
}
|
|
},
|
|
|
|
async markAllRead() {
|
|
try {
|
|
await fetch(this.markAllReadUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': this.csrfToken,
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
});
|
|
this.notifications = [];
|
|
this.unreadCount = 0;
|
|
} catch (e) {
|
|
console.error('Failed to mark all notifications as read', e);
|
|
}
|
|
},
|
|
|
|
getIconBg(icon) {
|
|
const map = { domain: 'bg-emerald-50', hosting: 'bg-violet-50', email: 'bg-pink-50', billing: 'bg-amber-50', success: 'bg-green-50' };
|
|
return map[icon] || 'bg-slate-100';
|
|
},
|
|
|
|
getIconColor(icon) {
|
|
const map = { domain: 'text-emerald-600', hosting: 'text-violet-600', email: 'text-pink-600', billing: 'text-amber-600', success: 'text-green-600' };
|
|
return map[icon] || 'text-slate-500';
|
|
},
|
|
}));
|
|
|
|
// Afia — Ladill in-app AI assistant (slide-over chat). Opened via the topbar AI button
|
|
// which dispatches a window 'afia-open' event. Greeting/suggestions are passed from Blade.
|
|
Alpine.data('afia', (config = {}) => ({
|
|
open: false,
|
|
input: '',
|
|
loading: false,
|
|
messages: [
|
|
{ role: 'assistant', text: config.greeting || "Hi, I'm Afia 👋 How can I help?" },
|
|
],
|
|
suggestions: config.suggestions || [],
|
|
init() {
|
|
window.addEventListener('afia-open', () => {
|
|
this.open = true;
|
|
this.$nextTick(() => this.$refs.input && this.$refs.input.focus());
|
|
});
|
|
},
|
|
close() { this.open = false; },
|
|
useSuggestion(s) { this.input = s; this.send(); },
|
|
scrollDown() {
|
|
this.$nextTick(() => { const el = this.$refs.scroll; if (el) el.scrollTop = el.scrollHeight; });
|
|
},
|
|
async send() {
|
|
const text = this.input.trim();
|
|
if (!text || this.loading) return;
|
|
const history = this.messages.map((m) => ({ role: m.role, text: m.text }));
|
|
this.messages.push({ role: 'user', text });
|
|
this.input = '';
|
|
this.loading = true;
|
|
this.scrollDown();
|
|
try {
|
|
const res = await fetch(config.chatUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': config.csrf, 'Accept': 'application/json' },
|
|
body: JSON.stringify({ message: text, history }),
|
|
});
|
|
const data = await res.json();
|
|
this.messages.push({ role: 'assistant', text: data.reply || data.message || 'Sorry, I could not respond.' });
|
|
} catch (e) {
|
|
this.messages.push({ role: 'assistant', text: 'Network error — please try again.' });
|
|
}
|
|
this.loading = false;
|
|
this.scrollDown();
|
|
},
|
|
}));
|
|
|
|
Alpine.data('topbarSearch', (config = {}) => ({
|
|
query: config.initialQuery || '',
|
|
results: Array.isArray(config.initialResults) ? config.initialResults : [],
|
|
open: !!config.openOnInit,
|
|
loading: false,
|
|
active: 0,
|
|
_abort: null,
|
|
searchUrl: config.searchUrl || '/search',
|
|
|
|
init() {
|
|
if (config.autoFocus) {
|
|
this.$nextTick(() => this.$refs.input?.focus());
|
|
}
|
|
},
|
|
|
|
onFocus() {
|
|
if (this.results.length > 0 || this.query.trim().length >= 2) this.open = true;
|
|
},
|
|
|
|
async search() {
|
|
const q = this.query.trim();
|
|
if (q.length < 2) { this.results = []; this.open = false; return; }
|
|
|
|
this.loading = true;
|
|
this.open = true;
|
|
this.active = 0;
|
|
|
|
if (this._abort) this._abort.abort();
|
|
this._abort = new AbortController();
|
|
|
|
try {
|
|
const res = await fetch(`${this.searchUrl}?q=${encodeURIComponent(q)}`, {
|
|
signal: this._abort.signal,
|
|
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
|
});
|
|
const data = await res.json();
|
|
this.results = data.results || [];
|
|
} catch (e) {
|
|
if (e.name !== 'AbortError') this.results = [];
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
moveDown() { if (this.active < this.results.length - 1) this.active++; },
|
|
moveUp() { if (this.active > 0) this.active--; },
|
|
go() {
|
|
if (this.results[this.active]) window.location.href = this.results[this.active].url;
|
|
},
|
|
}));
|
|
|
|
window.serverOrderForm = (products = {}, billingCycleLabels = {}, initial = {}) => ({
|
|
products,
|
|
billingCycleLabels,
|
|
steps: [{ label: 'Select Plan' }, { label: 'Configure' }, { label: 'Review' }],
|
|
step: Number(initial.step ?? 1),
|
|
selectedProductId: initial.selectedProductId ? String(initial.selectedProductId) : '',
|
|
selectedProduct: null,
|
|
serverName: String(initial.serverName ?? ''),
|
|
billingCycle: String(initial.billingCycle ?? 'monthly'),
|
|
selections: {
|
|
region: '',
|
|
image: '',
|
|
license: '',
|
|
application: '',
|
|
default_user: '',
|
|
additional_ip: '',
|
|
private_networking: '',
|
|
storage_type: '',
|
|
object_storage: '',
|
|
},
|
|
serverPassword: '',
|
|
serverPasswordConfirmation: '',
|
|
submitting: false,
|
|
error: '',
|
|
|
|
clone(value) {
|
|
return JSON.parse(JSON.stringify(value ?? null));
|
|
},
|
|
|
|
init() {
|
|
if (this.selectedProductId) {
|
|
this.applyProduct(this.selectedProductId, true);
|
|
}
|
|
},
|
|
|
|
applyProduct(productId, preserveState = false) {
|
|
const product = this.products[String(productId)] || null;
|
|
|
|
if (!product) {
|
|
return;
|
|
}
|
|
|
|
this.selectedProductId = String(productId);
|
|
this.selectedProduct = this.clone(product);
|
|
|
|
const defaults = this.clone(product.defaults || {});
|
|
const initialSelections = preserveState ? this.clone(initial.selections || {}) : {};
|
|
|
|
this.selections = {
|
|
...defaults,
|
|
...initialSelections,
|
|
};
|
|
|
|
if (!preserveState) {
|
|
this.serverName = '';
|
|
this.billingCycle = 'monthly';
|
|
this.serverPassword = '';
|
|
this.serverPasswordConfirmation = '';
|
|
}
|
|
|
|
if (!Object.prototype.hasOwnProperty.call(this.billingCycleLabels, this.billingCycle)) {
|
|
this.billingCycle = 'monthly';
|
|
}
|
|
|
|
this.syncDefaultUser();
|
|
this.syncLicenseSelection();
|
|
this.error = '';
|
|
},
|
|
|
|
selectProduct(productId) {
|
|
this.applyProduct(productId, false);
|
|
},
|
|
|
|
nextStep() {
|
|
if (this.step === 1 && !this.selectedProductId) {
|
|
return;
|
|
}
|
|
|
|
if (this.step === 2 && !this.serverName.trim()) {
|
|
return;
|
|
}
|
|
|
|
if (this.step < this.steps.length) {
|
|
this.step += 1;
|
|
}
|
|
},
|
|
|
|
catalog(key) {
|
|
return this.selectedProduct?.catalog?.[key] || [];
|
|
},
|
|
|
|
findOption(options, value) {
|
|
return (options || []).find((option) => option.value === value) || null;
|
|
},
|
|
|
|
optionAmount(option) {
|
|
return Number(option?.prices?.[this.billingCycle] || 0);
|
|
},
|
|
|
|
optionLabel(option) {
|
|
if (!option) {
|
|
return '';
|
|
}
|
|
|
|
const amount = this.optionAmount(option);
|
|
|
|
return amount > 0
|
|
? `${option.label} (+${this.formatMoney(amount)})`
|
|
: option.label;
|
|
},
|
|
|
|
selectionLabel(catalogKey, value) {
|
|
const option = this.findOption(this.catalog(catalogKey), value);
|
|
|
|
return option?.label || value || '-';
|
|
},
|
|
|
|
currentImage() {
|
|
return this.findOption(this.catalog('images'), this.selections.image);
|
|
},
|
|
|
|
imageSupportsManagedStack(image = null) {
|
|
const currentImage = image || this.currentImage();
|
|
const osFamily = String(currentImage?.os_family || this.selectedProduct?.catalog?.default_image_os_family || 'linux').toLowerCase();
|
|
const imageLabel = String(currentImage?.label || '').toLowerCase();
|
|
const installLater = Boolean(currentImage?.is_install_later);
|
|
|
|
if (installLater || osFamily.startsWith('win')) {
|
|
return false;
|
|
}
|
|
|
|
if (['cpanel', 'plesk', 'whm'].some((needle) => imageLabel.includes(needle))) {
|
|
return false;
|
|
}
|
|
|
|
return ['ubuntu 22.04', 'ubuntu 24.04', 'debian 12', 'bookworm', 'almalinux', 'alma linux', 'alma', 'rocky linux', 'rocky', 'fedora', 'opensuse leap', 'open suse leap', 'opensuse', 'leap', 'centos']
|
|
.some((needle) => imageLabel.includes(needle));
|
|
},
|
|
|
|
bundledPanelFromImage(image = null) {
|
|
const imageLabel = String((image || this.currentImage())?.label || '').toLowerCase();
|
|
|
|
if (imageLabel.includes('cpanel') || imageLabel.includes('whm')) {
|
|
return 'cpanel';
|
|
}
|
|
|
|
if (imageLabel.includes('plesk')) {
|
|
return 'plesk';
|
|
}
|
|
|
|
return null;
|
|
},
|
|
|
|
availableLicenseOptions() {
|
|
const image = this.currentImage();
|
|
const installLater = Boolean(image?.is_install_later);
|
|
const bundledPanel = this.bundledPanelFromImage(image);
|
|
|
|
return this.catalog('licenses').filter((option) => {
|
|
if (installLater) {
|
|
return option.value === 'none';
|
|
}
|
|
|
|
if (bundledPanel && option.value === 'none') {
|
|
return false;
|
|
}
|
|
|
|
const optionLicense = String(option.license || '').toLowerCase();
|
|
|
|
if (bundledPanel === 'cpanel' && optionLicense.startsWith('plesk')) {
|
|
return false;
|
|
}
|
|
|
|
if (bundledPanel === 'plesk' && optionLicense.startsWith('cpanel')) {
|
|
return false;
|
|
}
|
|
|
|
if (option.panel !== 'ladill') {
|
|
return true;
|
|
}
|
|
|
|
return this.imageSupportsManagedStack(image);
|
|
});
|
|
},
|
|
|
|
currentLicenseOption() {
|
|
return this.findOption(this.availableLicenseOptions(), this.selections.license)
|
|
|| this.findOption(this.catalog('licenses'), this.selections.license);
|
|
},
|
|
|
|
currentPanelSnapshot() {
|
|
const license = this.currentLicenseOption();
|
|
const image = this.currentImage();
|
|
const managedCandidate = this.imageSupportsManagedStack(image);
|
|
|
|
if (license?.panel === 'ladill') {
|
|
return {
|
|
label: license.label || 'Ladill Server Manager',
|
|
primary: managedCandidate
|
|
? 'Ladill Server Manager will provision this server with Ladill-managed controls on the selected supported Linux image.'
|
|
: 'Ladill Server Manager is not available for the selected image.',
|
|
secondary: managedCandidate
|
|
? 'After provisioning finishes, Server Manager can handle power, status, files, databases, domains, PHP, SSL, cron, and logs.'
|
|
: 'Choose a plain supported Linux image without cPanel or Plesk to use Ladill-managed hosting features.',
|
|
futureCapabilities: managedCandidate ? ['Files', 'Databases', 'Domains', 'PHP', 'SSL', 'Cron', 'Logs'] : [],
|
|
toneClasses: managedCandidate
|
|
? 'border-amber-200 bg-amber-50 text-amber-900'
|
|
: 'border-slate-200 bg-slate-50 text-slate-700',
|
|
};
|
|
}
|
|
|
|
if (license?.license) {
|
|
return {
|
|
label: license.label || 'Server Panel',
|
|
primary: `${license.label} will run inside the server as the primary control panel.`,
|
|
secondary: 'Ladill will keep the order and infrastructure lifecycle, but site and service management will happen in the installed panel.',
|
|
futureCapabilities: [],
|
|
toneClasses: 'border-blue-200 bg-blue-50 text-blue-900',
|
|
};
|
|
}
|
|
|
|
return {
|
|
label: license?.label || 'Remote Login Only',
|
|
primary: 'This selection provisions the server without a Ladill-managed or commercial control panel.',
|
|
secondary: 'You will manage the machine directly over SSH or RDP until a panel or managed runtime is installed separately.',
|
|
futureCapabilities: [],
|
|
toneClasses: 'border-gray-200 bg-gray-50 text-gray-700',
|
|
};
|
|
},
|
|
|
|
defaultUserOptions() {
|
|
const defaultFamily = this.selectedProduct?.catalog?.default_image_os_family || 'linux';
|
|
const osFamily = this.currentImage()?.os_family || defaultFamily;
|
|
|
|
return osFamily.startsWith('win')
|
|
? this.catalog('windows_default_users')
|
|
: this.catalog('linux_default_users');
|
|
},
|
|
|
|
syncDefaultUser() {
|
|
const options = this.defaultUserOptions();
|
|
|
|
if (!options.some((option) => option.value === this.selections.default_user)) {
|
|
this.selections.default_user = options[0]?.value || '';
|
|
}
|
|
},
|
|
|
|
syncLicenseSelection() {
|
|
const options = this.availableLicenseOptions();
|
|
|
|
if (!options.some((option) => option.value === this.selections.license)) {
|
|
this.selections.license = options[0]?.value || 'none';
|
|
}
|
|
},
|
|
|
|
handleImageChange() {
|
|
this.syncDefaultUser();
|
|
this.syncLicenseSelection();
|
|
},
|
|
|
|
basePrice() {
|
|
return Number(this.selectedProduct?.prices?.[this.billingCycle] || 0);
|
|
},
|
|
|
|
setupFee() {
|
|
return Number(this.selectedProduct?.setup_fees?.[this.billingCycle] || 0);
|
|
},
|
|
|
|
addOnLineItems() {
|
|
const items = [];
|
|
const setupFee = this.setupFee();
|
|
|
|
if (setupFee > 0) {
|
|
items.push({
|
|
key: 'setup_fee',
|
|
label: 'One-time setup fee',
|
|
amount: setupFee,
|
|
});
|
|
}
|
|
|
|
const keys = ['image', 'region', 'license', 'application', 'additional_ip', 'private_networking', 'storage_type', 'object_storage'];
|
|
const catalogKeys = {
|
|
image: 'images',
|
|
region: 'regions',
|
|
license: 'licenses',
|
|
application: 'applications',
|
|
additional_ip: 'additional_ip',
|
|
private_networking: 'private_networking',
|
|
storage_type: 'storage_types',
|
|
object_storage: 'object_storage',
|
|
};
|
|
|
|
keys.forEach((key) => {
|
|
const option = this.findOption(this.catalog(catalogKeys[key]), this.selections[key]);
|
|
const amount = this.optionAmount(option);
|
|
|
|
if (amount > 0) {
|
|
items.push({
|
|
key,
|
|
label: option.label,
|
|
amount,
|
|
});
|
|
}
|
|
});
|
|
|
|
return items;
|
|
},
|
|
|
|
currentTotal() {
|
|
return this.basePrice() + this.addOnLineItems().reduce((sum, item) => sum + item.amount, 0);
|
|
},
|
|
|
|
cycleMonths() {
|
|
return {
|
|
monthly: 1,
|
|
quarterly: 3,
|
|
semiannual: 6,
|
|
yearly: 12,
|
|
}[this.billingCycle] || 1;
|
|
},
|
|
|
|
monthlyEquivalent() {
|
|
return this.currentTotal() / this.cycleMonths();
|
|
},
|
|
|
|
formatMoney(amount, currency = null) {
|
|
if (amount === null || amount === undefined) {
|
|
return '-';
|
|
}
|
|
|
|
const code = currency || this.selectedProduct?.currency || 'GHS';
|
|
|
|
return `${code} ${Number(amount).toLocaleString('en-US', {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
})}`;
|
|
},
|
|
|
|
canSubmit() {
|
|
return Boolean(
|
|
this.selectedProductId
|
|
&& this.serverName.trim()
|
|
&& this.serverPassword
|
|
&& this.serverPassword === this.serverPasswordConfirmation
|
|
&& this.serverPassword.length >= 8
|
|
);
|
|
},
|
|
|
|
submitOrder() {
|
|
if (!this.canSubmit()) {
|
|
this.error = 'Complete the required fields and make sure the passwords match.';
|
|
return;
|
|
}
|
|
|
|
this.submitting = true;
|
|
this.error = '';
|
|
this.$refs.form.submit();
|
|
},
|
|
});
|
|
|
|
window.Alpine = Alpine;
|
|
registerLadillConfirmStore(Alpine);
|
|
|
|
Alpine.start();
|