1888 lines
77 KiB
HTML
1888 lines
77 KiB
HTML
{% extends "common/base.html" %}
|
|
{% from "components/header.html" import header %}
|
|
|
|
{% block title %}Instances - DocuPulse{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
.version-info {
|
|
font-size: 0.85rem;
|
|
}
|
|
.version-info code {
|
|
font-size: 0.8rem;
|
|
background-color: #f8f9fa;
|
|
padding: 2px 4px;
|
|
border-radius: 3px;
|
|
}
|
|
.badge-sm {
|
|
font-size: 0.7rem;
|
|
padding: 0.25rem 0.5rem;
|
|
}
|
|
.table td {
|
|
vertical-align: middle;
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
{{ header(
|
|
title="Instances",
|
|
description="Manage your DocuPulse instances",
|
|
icon="fa-server",
|
|
buttons=[
|
|
{
|
|
'text': 'Launch New Instance',
|
|
'url': '#',
|
|
'icon': 'fa-rocket',
|
|
'class': 'btn-primary',
|
|
'onclick': 'showAddInstanceModal()'
|
|
},
|
|
{
|
|
'text': 'Add Existing Instance',
|
|
'url': '#',
|
|
'icon': 'fa-link',
|
|
'class': 'btn-primary',
|
|
'onclick': 'showAddExistingInstanceModal()'
|
|
}
|
|
]
|
|
) }}
|
|
|
|
<div class="container-fluid">
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover">
|
|
<thead>
|
|
<tr>
|
|
<th>PORT</th>
|
|
<th>Company</th>
|
|
<th>Rooms</th>
|
|
<th>Conversations</th>
|
|
<th>Data</th>
|
|
<th>Payment Plan</th>
|
|
<th>Main URL</th>
|
|
<th>Status</th>
|
|
<th>Version</th>
|
|
<th>Connection Token</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for instance in instances %}
|
|
<tr>
|
|
<td>{{ instance.name }}</td>
|
|
<td>{{ instance.company }}</td>
|
|
<td>{{ instance.rooms_count }}</td>
|
|
<td>{{ instance.conversations_count }}</td>
|
|
<td>{{ "%.1f"|format(instance.data_size) }} GB</td>
|
|
<td>{{ instance.payment_plan }}</td>
|
|
<td>
|
|
<a href="{{ instance.main_url }}"
|
|
target="_blank"
|
|
class="text-decoration-none"
|
|
style="color: var(--primary-color);">
|
|
{{ instance.main_url }}
|
|
<i class="fas fa-external-link-alt ms-1"></i>
|
|
</a>
|
|
</td>
|
|
<td>
|
|
<span class="badge bg-{{ 'success' if instance.status == 'active' else 'danger' }}"
|
|
data-bs-toggle="tooltip"
|
|
data-instance-id="{{ instance.id }}"
|
|
data-token="{{ instance.connection_token }}"
|
|
title="{{ instance.status_details }}">
|
|
{{ instance.status|title }}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
{% if instance.deployed_version and instance.deployed_version != 'unknown' %}
|
|
<div class="version-info">
|
|
<div class="small">
|
|
<strong>Version:</strong>
|
|
<code class="text-primary">{{ instance.deployed_version }}</code>
|
|
</div>
|
|
{% if instance.latest_version and instance.latest_version != 'unknown' %}
|
|
<div class="small">
|
|
<strong>Latest:</strong>
|
|
<code class="text-secondary">{{ instance.latest_version }}</code>
|
|
</div>
|
|
{% if instance.deployed_version == instance.latest_version %}
|
|
<span class="badge bg-success badge-sm" data-bs-toggle="tooltip" title="Instance is up to date">
|
|
<i class="fas fa-check"></i> Up-to-date
|
|
</span>
|
|
{% else %}
|
|
<span class="badge bg-warning badge-sm" data-bs-toggle="tooltip" title="Instance is outdated">
|
|
<i class="fas fa-exclamation-triangle"></i> Outdated
|
|
</span>
|
|
{% endif %}
|
|
{% else %}
|
|
<span class="badge bg-secondary badge-sm" data-bs-toggle="tooltip" title="Latest version unknown">
|
|
<i class="fas fa-question"></i> Unknown
|
|
</span>
|
|
{% endif %}
|
|
{% if instance.version_checked_at %}
|
|
<div class="small text-muted">
|
|
<i class="fas fa-clock"></i> {{ instance.version_checked_at.strftime('%H:%M') }}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
{% else %}
|
|
<span class="badge bg-secondary" data-bs-toggle="tooltip" title="Version information not available">
|
|
<i class="fas fa-question"></i> Unknown
|
|
</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
{% if instance.connection_token %}
|
|
<span class="badge bg-success" data-bs-toggle="tooltip" title="Instance is authenticated">
|
|
Authenticated
|
|
</span>
|
|
{% else %}
|
|
<span class="badge bg-warning" data-bs-toggle="tooltip" title="Click to authenticate instance">
|
|
<a href="#" class="text-dark text-decoration-none" onclick="showAuthModal('{{ instance.main_url }}', {{ instance.id }})">
|
|
Generate Token
|
|
</a>
|
|
</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
<div class="btn-group">
|
|
<button class="btn btn-sm btn-outline-primary" onclick="editInstance({{ instance.id }})">
|
|
<i class="fas fa-edit"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-info" onclick="window.location.href='/instances/{{ instance.id }}/detail'">
|
|
<i class="fas fa-info-circle"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteInstance({{ instance.id }})">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add Instance Modal -->
|
|
<div class="modal fade" id="addInstanceModal" tabindex="-1">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Launch New Instance</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="addInstanceForm">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
<div class="text-center mb-4">
|
|
<i class="fas fa-rocket fa-3x mb-3" style="color: var(--primary-color);"></i>
|
|
<h4>Ready to Launch?</h4>
|
|
<p class="text-muted">We'll guide you through the process of launching a new DocuPulse instance.</p>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-primary" onclick="nextStepLaunchInstance()">
|
|
Next <i class="fas fa-arrow-right ms-1"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Launch Steps Modal -->
|
|
<div class="modal fade" id="launchStepsModal" tabindex="-1">
|
|
<div class="modal-dialog modal-dialog-centered modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Launch New Instance</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<!-- Steps Navigation -->
|
|
<div class="d-flex justify-content-between mb-4">
|
|
<div class="step-item active" data-step="1">
|
|
<div class="step-circle">1</div>
|
|
<div class="step-label">Connections</div>
|
|
</div>
|
|
<div class="step-item" data-step="2">
|
|
<div class="step-circle">2</div>
|
|
<div class="step-label">Repository</div>
|
|
</div>
|
|
<div class="step-item" data-step="3">
|
|
<div class="step-circle">3</div>
|
|
<div class="step-label">Company</div>
|
|
</div>
|
|
<div class="step-item" data-step="4">
|
|
<div class="step-circle">4</div>
|
|
<div class="step-label">Variables</div>
|
|
</div>
|
|
<div class="step-item" data-step="5">
|
|
<div class="step-circle">5</div>
|
|
<div class="step-label">White Label</div>
|
|
</div>
|
|
<div class="step-item" data-step="6">
|
|
<div class="step-circle">6</div>
|
|
<div class="step-label">Launch</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step Content -->
|
|
<div class="step-content">
|
|
<!-- Step 1 -->
|
|
<div class="step-pane active" id="step1">
|
|
<!-- Portainer Connection -->
|
|
<div class="connection-check mb-4">
|
|
<div class="d-flex align-items-center mb-2">
|
|
<i class="fas fa-server me-2"></i>
|
|
<h5 class="mb-0">Portainer Connection</h5>
|
|
<div class="ms-auto">
|
|
<span class="connection-status" id="portainerStatus">
|
|
<i class="fas fa-spinner fa-spin"></i> Checking...
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="connection-details" id="portainerDetails">
|
|
<small class="text-muted">Verifying connection to Portainer...</small>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- NGINX Connection -->
|
|
<div class="connection-check">
|
|
<div class="d-flex align-items-center mb-2">
|
|
<i class="fas fa-network-wired me-2"></i>
|
|
<h5 class="mb-0">NGINX Proxy Manager Connection</h5>
|
|
<div class="ms-auto">
|
|
<span class="connection-status" id="nginxStatus">
|
|
<i class="fas fa-spinner fa-spin"></i> Checking...
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="connection-details" id="nginxDetails">
|
|
<small class="text-muted">Verifying connection to NGINX Proxy Manager...</small>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Retry Button -->
|
|
<div class="text-center mt-4">
|
|
<button class="btn btn-sm"
|
|
onclick="retryConnection('nginx')"
|
|
style="background-color: var(--primary-color); color: white;">
|
|
<i class="fas fa-sync-alt me-1"></i> Retry
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 2 -->
|
|
<div class="step-pane" id="step2">
|
|
<div class="step-content" id="step2">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">Repository</label>
|
|
<select class="form-select" id="giteaRepoSelect">
|
|
<option value="">Loading repositories...</option>
|
|
</select>
|
|
<div class="form-text">Select the repository containing your application code</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Branch</label>
|
|
<select class="form-select" id="giteaBranchSelect" disabled>
|
|
<option value="">Select a repository first</option>
|
|
</select>
|
|
<div class="form-text">Select the branch to deploy</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 3 -->
|
|
<div class="step-pane" id="step3">
|
|
<div class="step-content">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div class="row">
|
|
<div class="col-md-6 mb-3">
|
|
<label class="form-label">Company Name <span class="text-danger">*</span></label>
|
|
<input type="text" class="form-control" id="companyName" required>
|
|
</div>
|
|
<div class="col-md-6 mb-3">
|
|
<label class="form-label">Industry</label>
|
|
<input type="text" class="form-control" id="industry">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label">Street Address <span class="text-danger">*</span></label>
|
|
<input type="text" class="form-control" id="streetAddress" required>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<div class="col-md-6 mb-3">
|
|
<label class="form-label">City <span class="text-danger">*</span></label>
|
|
<input type="text" class="form-control" id="city" required>
|
|
</div>
|
|
<div class="col-md-3 mb-3">
|
|
<label class="form-label">State</label>
|
|
<input type="text" class="form-control" id="state">
|
|
</div>
|
|
<div class="col-md-3 mb-3">
|
|
<label class="form-label">ZIP Code <span class="text-danger">*</span></label>
|
|
<input type="text" class="form-control" id="zipCode" required>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label">Country <span class="text-danger">*</span></label>
|
|
<input type="text" class="form-control" id="country" required>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<div class="col-md-4 mb-3">
|
|
<label class="form-label">Website</label>
|
|
<input type="url" class="form-control" id="website">
|
|
</div>
|
|
<div class="col-md-4 mb-3">
|
|
<label class="form-label">Email <span class="text-danger">*</span></label>
|
|
<input type="email" class="form-control" id="email" required>
|
|
</div>
|
|
<div class="col-md-4 mb-3">
|
|
<label class="form-label">Phone <span class="text-danger">*</span></label>
|
|
<input type="tel" class="form-control" id="phone" required>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label">Company Description</label>
|
|
<textarea class="form-control" id="companyDescription" rows="4"></textarea>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 4 -->
|
|
<div class="step-pane" id="step4">
|
|
<h5 class="mb-4">Port Configuration</h5>
|
|
<div class="mb-3">
|
|
<label for="port" class="form-label">Internal Port Number <span class="text-danger">*</span></label>
|
|
<input type="number" class="form-control" id="port" required min="1024" max="65535">
|
|
<div class="form-text">Ports below 1024 are reserved for system use. Ports are automatically assigned by the system but you can change it if you want.</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Web Addresses <span class="text-danger">*</span></label>
|
|
<div id="webAddressesContainer">
|
|
<div class="input-group mb-2">
|
|
<span class="input-group-text">https://</span>
|
|
<input type="text" class="form-control web-address" required placeholder="your-domain.com">
|
|
<button type="button" class="btn btn-outline-danger remove-address" onclick="removeWebAddress(this)" style="display: none;">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<button type="button" class="btn btn-outline-primary btn-sm" onclick="addWebAddress()">
|
|
<i class="fas fa-plus"></i> Add Another Address
|
|
</button>
|
|
<div class="form-text">Enter the domain names where your instance will be accessible</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 5: White Label -->
|
|
<div class="step-pane" id="step5">
|
|
<div class="step-content">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<h5 class="card-title mb-4">White Label Configuration</h5>
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<div class="mb-3">
|
|
<label class="form-label">Primary Color</label>
|
|
<div class="input-group">
|
|
<input type="color" class="form-control form-control-color" id="primaryColor" value="#16767B" title="Choose primary color">
|
|
<input type="text" class="form-control" id="primaryColorRGB" value="22,118,123" readonly>
|
|
</div>
|
|
<div class="form-text">Default: RGB(22,118,123)</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<div class="mb-3">
|
|
<label class="form-label">Secondary Color</label>
|
|
<div class="input-group">
|
|
<input type="color" class="form-control form-control-color" id="secondaryColor" value="#741B5F" title="Choose secondary color">
|
|
<input type="text" class="form-control" id="secondaryColorRGB" value="116,27,95" readonly>
|
|
</div>
|
|
<div class="form-text">Default: RGB(116,27,95)</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mt-4">
|
|
<h6>Preview</h6>
|
|
<div id="previewContainer">
|
|
<div class="preview-box p-3 rounded mb-2" id="primaryPreview" style="background-color: #16767B; color: white;">
|
|
Primary Color Sample
|
|
</div>
|
|
<div class="preview-box p-3 rounded" id="secondaryPreview" style="background-color: #741B5F; color: white;">
|
|
Secondary Color Sample
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 6 -->
|
|
<div class="step-pane" id="step6">
|
|
<div class="text-center">
|
|
<i class="fas fa-rocket fa-4x mb-4" style="color: var(--primary-color);"></i>
|
|
<h4>Ready to Launch!</h4>
|
|
<p class="text-muted mb-4">Review your instance configuration before launching</p>
|
|
|
|
<div class="card mb-4">
|
|
<div class="card-body">
|
|
<h5 class="card-title mb-3">Instance Configuration</h5>
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<p><strong>Repository:</strong> <span id="reviewRepo"></span></p>
|
|
<p><strong>Branch:</strong> <span id="reviewBranch"></span></p>
|
|
<p><strong>Company:</strong> <span id="reviewCompany"></span></p>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<p><strong>Port:</strong> <span id="reviewPort"></span></p>
|
|
<p><strong>Web Addresses:</strong> <span id="reviewWebAddresses"></span></p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button type="button" class="btn btn-primary btn-lg" onclick="launchInstance()">
|
|
<i class="fas fa-rocket me-2"></i> Launch Instance
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer d-flex justify-content-end" id="launchStepsFooter">
|
|
<button type="button" class="btn btn-secondary" onclick="previousStep()">
|
|
<i class="fas fa-arrow-left me-1"></i> Previous
|
|
</button>
|
|
<button type="button" class="btn btn-primary" onclick="nextStep()">
|
|
Next <i class="fas fa-arrow-right ms-1"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.step-item {
|
|
text-align: center;
|
|
position: relative;
|
|
flex: 1;
|
|
}
|
|
|
|
.step-item:not(:last-child)::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 20px;
|
|
right: -50%;
|
|
width: 100%;
|
|
height: 2px;
|
|
background-color: #e9ecef;
|
|
z-index: 1;
|
|
}
|
|
|
|
.step-circle {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
background-color: #e9ecef;
|
|
color: #6c757d;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin: 0 auto 8px;
|
|
position: relative;
|
|
z-index: 2;
|
|
}
|
|
|
|
.step-label {
|
|
font-size: 0.875rem;
|
|
color: #6c757d;
|
|
}
|
|
|
|
.step-item.active .step-circle {
|
|
background-color: var(--primary-color);
|
|
color: white;
|
|
}
|
|
|
|
.step-item.active .step-label {
|
|
color: var(--primary-color);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.step-item.completed .step-circle {
|
|
background-color: var(--primary-color);
|
|
color: white;
|
|
}
|
|
|
|
.step-item.completed:not(:last-child)::after {
|
|
background-color: var(--primary-color);
|
|
}
|
|
|
|
.step-pane {
|
|
display: none;
|
|
}
|
|
|
|
.step-pane.active {
|
|
display: block;
|
|
}
|
|
|
|
.connection-check {
|
|
background-color: #f8f9fa;
|
|
border-radius: 8px;
|
|
padding: 1rem;
|
|
}
|
|
|
|
.connection-status {
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.connection-status.success {
|
|
color: #198754;
|
|
}
|
|
|
|
.connection-status.error {
|
|
color: #dc3545;
|
|
}
|
|
|
|
.connection-details {
|
|
font-size: 0.875rem;
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
.modal-footer {
|
|
display: flex;
|
|
justify-content: flex-end !important;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.modal-footer button {
|
|
margin-left: 0.5rem;
|
|
}
|
|
</style>
|
|
|
|
<!-- Edit Instance Modal -->
|
|
<div class="modal fade" id="editInstanceModal" tabindex="-1">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Edit Instance</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="editInstanceForm">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
<input type="hidden" id="edit_instance_id">
|
|
<div class="mb-3">
|
|
<label for="edit_name" class="form-label">Instance Name</label>
|
|
<input type="text" class="form-control" id="edit_name" name="name" required>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="edit_main_url" class="form-label">Main URL</label>
|
|
<input type="url" class="form-control" id="edit_main_url" name="main_url" required>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-primary" onclick="submitEditInstance()">Save Changes</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add Existing Instance Modal -->
|
|
<div class="modal fade" id="addExistingInstanceModal" tabindex="-1">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Add Existing Instance</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="addExistingInstanceForm">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
<div class="mb-3">
|
|
<label for="existing_url" class="form-label">Instance URL</label>
|
|
<input type="url" class="form-control" id="existing_url" name="url" required onchange="updateInstanceFields()">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="existing_name" class="form-label">Instance Name</label>
|
|
<input type="text" class="form-control" id="existing_name" name="name" required>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-primary" onclick="submitAddExistingInstance()">Add Instance</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Auth Modal -->
|
|
<div class="modal fade" id="authModal" tabindex="-1">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Authenticate Instance</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="authForm">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
<input type="hidden" id="instance_url" name="instance_url">
|
|
<input type="hidden" id="instance_id" name="instance_id">
|
|
<div class="mb-3">
|
|
<label for="email" class="form-label">Email</label>
|
|
<input type="email" class="form-control" id="email" name="email" required>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="password" class="form-label">Password</label>
|
|
<input type="password" class="form-control" id="password" name="password" required>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-primary" onclick="authenticateInstance()">Authenticate</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
// Modal instances
|
|
let addInstanceModal;
|
|
let editInstanceModal;
|
|
let addExistingInstanceModal;
|
|
let authModal;
|
|
let launchStepsModal;
|
|
let currentStep = 1;
|
|
|
|
// Update the total number of steps
|
|
const totalSteps = 6;
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
addInstanceModal = new bootstrap.Modal(document.getElementById('addInstanceModal'));
|
|
editInstanceModal = new bootstrap.Modal(document.getElementById('editInstanceModal'));
|
|
addExistingInstanceModal = new bootstrap.Modal(document.getElementById('addExistingInstanceModal'));
|
|
authModal = new bootstrap.Modal(document.getElementById('authModal'));
|
|
launchStepsModal = new bootstrap.Modal(document.getElementById('launchStepsModal'));
|
|
|
|
// Initialize tooltips
|
|
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
|
tooltipTriggerList.map(function (tooltipTriggerEl) {
|
|
return new bootstrap.Tooltip(tooltipTriggerEl);
|
|
});
|
|
|
|
// Add refresh button to header
|
|
const headerButtons = document.querySelector('.header-buttons');
|
|
if (headerButtons) {
|
|
const refreshButton = document.createElement('button');
|
|
refreshButton.className = 'btn btn-outline-primary';
|
|
refreshButton.innerHTML = '<i class="fas fa-sync-alt"></i> Refresh';
|
|
refreshButton.onclick = function() {
|
|
fetchCompanyNames();
|
|
};
|
|
headerButtons.appendChild(refreshButton);
|
|
}
|
|
|
|
// Wait a short moment to ensure the table is rendered
|
|
setTimeout(() => {
|
|
// Check statuses on page load
|
|
checkAllInstanceStatuses();
|
|
|
|
// Fetch company names for all instances
|
|
fetchCompanyNames();
|
|
}, 100);
|
|
|
|
// Set up periodic status checks (every 30 seconds)
|
|
setInterval(checkAllInstanceStatuses, 30000);
|
|
|
|
// Update color picker functionality
|
|
const primaryColor = document.getElementById('primaryColor');
|
|
const secondaryColor = document.getElementById('secondaryColor');
|
|
const primaryColorRGB = document.getElementById('primaryColorRGB');
|
|
const secondaryColorRGB = document.getElementById('secondaryColorRGB');
|
|
const primaryPreview = document.getElementById('primaryPreview');
|
|
const secondaryPreview = document.getElementById('secondaryPreview');
|
|
|
|
function updateColorPreview() {
|
|
// Update preview boxes directly instead of using CSS variables
|
|
primaryPreview.style.backgroundColor = primaryColor.value;
|
|
secondaryPreview.style.backgroundColor = secondaryColor.value;
|
|
}
|
|
|
|
function hexToRgb(hex) {
|
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
return result ?
|
|
`${parseInt(result[1], 16)},${parseInt(result[2], 16)},${parseInt(result[3], 16)}` :
|
|
null;
|
|
}
|
|
|
|
function rgbToHex(r, g, b) {
|
|
return '#' + [r, g, b].map(x => {
|
|
const hex = x.toString(16);
|
|
return hex.length === 1 ? '0' + hex : hex;
|
|
}).join('');
|
|
}
|
|
|
|
primaryColor.addEventListener('input', function() {
|
|
primaryColorRGB.value = hexToRgb(this.value);
|
|
updateColorPreview();
|
|
});
|
|
|
|
secondaryColor.addEventListener('input', function() {
|
|
secondaryColorRGB.value = hexToRgb(this.value);
|
|
updateColorPreview();
|
|
});
|
|
|
|
// Initialize color preview
|
|
updateColorPreview();
|
|
});
|
|
|
|
// Function to check status of all instances
|
|
async function checkAllInstanceStatuses() {
|
|
const statusBadges = document.querySelectorAll('[data-instance-id]');
|
|
for (const badge of statusBadges) {
|
|
const instanceId = badge.dataset.instanceId;
|
|
await checkInstanceStatus(instanceId);
|
|
}
|
|
}
|
|
|
|
// Function to check status of a single instance
|
|
async function checkInstanceStatus(instanceId) {
|
|
try {
|
|
const response = await fetch(`/instances/${instanceId}/status`);
|
|
if (!response.ok) throw new Error('Failed to check instance status');
|
|
|
|
const data = await response.json();
|
|
const badge = document.querySelector(`[data-instance-id="${instanceId}"]`);
|
|
if (badge) {
|
|
badge.className = `badge bg-${data.status === 'active' ? 'success' : 'danger'}`;
|
|
badge.textContent = data.status.charAt(0).toUpperCase() + data.status.slice(1);
|
|
|
|
// Parse the JSON string in status_details
|
|
let tooltipContent = data.status;
|
|
if (data.status_details) {
|
|
try {
|
|
const details = JSON.parse(data.status_details);
|
|
tooltipContent = `Status: ${details.status}\nTimestamp: ${details.timestamp}`;
|
|
if (details.database) {
|
|
tooltipContent += `\nDatabase: ${details.database}`;
|
|
}
|
|
} catch (e) {
|
|
tooltipContent = data.status_details;
|
|
}
|
|
}
|
|
badge.title = tooltipContent;
|
|
|
|
// Update tooltip
|
|
const tooltip = bootstrap.Tooltip.getInstance(badge);
|
|
if (tooltip) {
|
|
tooltip.dispose();
|
|
}
|
|
new bootstrap.Tooltip(badge);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error checking instance status:', error);
|
|
}
|
|
}
|
|
|
|
// Function to get JWT token using API key
|
|
async function getJWTToken(instanceUrl, apiKey) {
|
|
try {
|
|
const response = await fetch(`${instanceUrl}/api/admin/management-token`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'X-API-Key': apiKey
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.error(`HTTP error ${response.status}:`, errorText);
|
|
throw new Error(`Server returned ${response.status}: ${errorText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
return data.token;
|
|
} catch (error) {
|
|
console.error('Error getting JWT token:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Function to fetch instance statistics
|
|
async function fetchInstanceStats(instanceUrl, instanceId, jwtToken) {
|
|
try {
|
|
const response = await fetch(`${instanceUrl}/api/admin/statistics`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'Authorization': `Bearer ${jwtToken}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.error(`HTTP error ${response.status}:`, errorText);
|
|
throw new Error(`Server returned ${response.status}: ${errorText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
console.log('Received stats data:', data);
|
|
|
|
const row = document.querySelector(`[data-instance-id="${instanceId}"]`).closest('tr');
|
|
|
|
// Update rooms count
|
|
const roomsCell = row.querySelector('td:nth-child(3)');
|
|
if (roomsCell) {
|
|
roomsCell.textContent = data.rooms || '0';
|
|
}
|
|
|
|
// Update conversations count
|
|
const conversationsCell = row.querySelector('td:nth-child(4)');
|
|
if (conversationsCell) {
|
|
conversationsCell.textContent = data.conversations || '0';
|
|
}
|
|
|
|
// Update data usage
|
|
const dataCell = row.querySelector('td:nth-child(5)');
|
|
if (dataCell) {
|
|
const dataSize = data.total_storage || 0;
|
|
const dataSizeGB = (dataSize / (1024 * 1024 * 1024)).toFixed(1);
|
|
dataCell.textContent = `${dataSizeGB} GB`;
|
|
}
|
|
|
|
return data;
|
|
} catch (error) {
|
|
console.error('Error fetching instance stats:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Function to fetch company name from instance settings
|
|
async function fetchCompanyName(instanceUrl, instanceId) {
|
|
const row = document.querySelector(`[data-instance-id="${instanceId}"]`).closest('tr');
|
|
const companyCell = row.querySelector('td:nth-child(2)');
|
|
|
|
// Show loading state
|
|
if (companyCell) {
|
|
companyCell.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading...';
|
|
}
|
|
|
|
try {
|
|
const apiKey = document.querySelector(`[data-instance-id="${instanceId}"]`).dataset.token;
|
|
if (!apiKey) {
|
|
throw new Error('No API key available');
|
|
}
|
|
|
|
console.log(`Getting JWT token for instance ${instanceId}`);
|
|
const jwtToken = await getJWTToken(instanceUrl, apiKey);
|
|
console.log('Got JWT token');
|
|
|
|
// Fetch company name
|
|
console.log(`Fetching company name for instance ${instanceId} from ${instanceUrl}/api/admin/settings`);
|
|
const response = await fetch(`${instanceUrl}/api/admin/settings`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'Authorization': `Bearer ${jwtToken}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.error(`HTTP error ${response.status}:`, errorText);
|
|
throw new Error(`Server returned ${response.status}: ${errorText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
console.log('Received data:', data);
|
|
|
|
if (companyCell) {
|
|
companyCell.textContent = data.company_name || 'N/A';
|
|
}
|
|
|
|
// Fetch statistics using the same JWT token
|
|
await fetchInstanceStats(instanceUrl, instanceId, jwtToken);
|
|
|
|
} catch (error) {
|
|
console.error('Error fetching company name:', error);
|
|
if (companyCell) {
|
|
const errorMessage = error.message || 'Unknown error';
|
|
companyCell.innerHTML = `
|
|
<span class="text-danger"
|
|
data-bs-toggle="tooltip"
|
|
data-bs-placement="top"
|
|
title="${errorMessage}">
|
|
<i class="fas fa-exclamation-circle"></i> Error
|
|
</span>`;
|
|
|
|
// Initialize tooltip
|
|
new bootstrap.Tooltip(companyCell.querySelector('[data-bs-toggle="tooltip"]'));
|
|
|
|
// Also show error in stats cells
|
|
const row = document.querySelector(`[data-instance-id="${instanceId}"]`).closest('tr');
|
|
const statsCells = [
|
|
row.querySelector('td:nth-child(3)'), // Rooms
|
|
row.querySelector('td:nth-child(4)'), // Conversations
|
|
row.querySelector('td:nth-child(5)') // Data
|
|
];
|
|
|
|
statsCells.forEach(cell => {
|
|
if (cell) {
|
|
cell.innerHTML = `
|
|
<span class="text-danger"
|
|
data-bs-toggle="tooltip"
|
|
data-bs-placement="top"
|
|
title="${errorMessage}">
|
|
<i class="fas fa-exclamation-circle"></i> Error
|
|
</span>`;
|
|
new bootstrap.Tooltip(cell.querySelector('[data-bs-toggle="tooltip"]'));
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Function to fetch company names for all instances
|
|
async function fetchCompanyNames() {
|
|
const instances = document.querySelectorAll('[data-instance-id]');
|
|
const loadingPromises = [];
|
|
|
|
console.log('Starting to fetch company names and stats for all instances');
|
|
|
|
for (const instance of instances) {
|
|
const instanceId = instance.dataset.instanceId;
|
|
const row = instance.closest('tr');
|
|
|
|
// Debug: Log all cells in the row
|
|
console.log(`Row for instance ${instanceId}:`, {
|
|
cells: Array.from(row.querySelectorAll('td')).map(td => ({
|
|
text: td.textContent.trim(),
|
|
html: td.innerHTML.trim()
|
|
}))
|
|
});
|
|
|
|
// Changed from nth-child(8) to nth-child(7) since Main URL is the 7th column
|
|
const urlCell = row.querySelector('td:nth-child(7)');
|
|
|
|
if (!urlCell) {
|
|
console.error(`Could not find URL cell for instance ${instanceId}`);
|
|
continue;
|
|
}
|
|
|
|
const urlLink = urlCell.querySelector('a');
|
|
if (!urlLink) {
|
|
console.error(`Could not find URL link for instance ${instanceId}`);
|
|
continue;
|
|
}
|
|
|
|
const instanceUrl = urlLink.getAttribute('href');
|
|
const token = instance.dataset.token;
|
|
|
|
console.log(`Instance ${instanceId}:`, {
|
|
url: instanceUrl,
|
|
hasToken: !!token
|
|
});
|
|
|
|
if (instanceUrl && token) {
|
|
loadingPromises.push(fetchCompanyName(instanceUrl, instanceId));
|
|
} else {
|
|
const cells = [
|
|
row.querySelector('td:nth-child(2)'), // Company
|
|
row.querySelector('td:nth-child(3)'), // Rooms
|
|
row.querySelector('td:nth-child(4)'), // Conversations
|
|
row.querySelector('td:nth-child(5)') // Data
|
|
];
|
|
|
|
cells.forEach(cell => {
|
|
if (cell) {
|
|
cell.innerHTML = `
|
|
<span class="text-warning"
|
|
data-bs-toggle="tooltip"
|
|
data-bs-placement="top"
|
|
title="${!instanceUrl ? 'No URL available' : 'No authentication token available'}">
|
|
<i class="fas fa-exclamation-triangle"></i> Not configured
|
|
</span>`;
|
|
new bootstrap.Tooltip(cell.querySelector('[data-bs-toggle="tooltip"]'));
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
try {
|
|
await Promise.all(loadingPromises);
|
|
console.log('Finished fetching all company names and stats');
|
|
} catch (error) {
|
|
console.error('Error in fetchCompanyNames:', error);
|
|
}
|
|
}
|
|
|
|
// Show modals
|
|
function showAddInstanceModal() {
|
|
document.getElementById('addInstanceForm').reset();
|
|
addInstanceModal.show();
|
|
}
|
|
|
|
function showAddExistingInstanceModal() {
|
|
document.getElementById('addExistingInstanceForm').reset();
|
|
addExistingInstanceModal.show();
|
|
}
|
|
|
|
// CRUD operations
|
|
async function submitAddInstance() {
|
|
const form = document.getElementById('addInstanceForm');
|
|
const formData = new FormData(form);
|
|
const data = Object.fromEntries(formData.entries());
|
|
|
|
try {
|
|
const response = await fetch('/instances/add', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': formData.get('csrf_token')
|
|
},
|
|
body: JSON.stringify({
|
|
name: data.name,
|
|
company: data.name.charAt(0).toUpperCase() + data.name.slice(1),
|
|
rooms_count: 0,
|
|
conversations_count: 0,
|
|
data_size: 0.0,
|
|
payment_plan: 'Basic',
|
|
main_url: data.main_url,
|
|
status: 'inactive'
|
|
})
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to add instance');
|
|
|
|
const result = await response.json();
|
|
addInstanceModal.hide();
|
|
location.reload(); // Refresh to show new instance
|
|
} catch (error) {
|
|
alert('Error adding instance: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async function editInstance(id) {
|
|
try {
|
|
// Find the row by looking for the edit button with the matching onclick handler
|
|
const editButton = document.querySelector(`button[onclick="editInstance(${id})"]`);
|
|
if (!editButton) {
|
|
throw new Error('Instance row not found');
|
|
}
|
|
const row = editButton.closest('tr');
|
|
if (!row) {
|
|
throw new Error('Instance row not found');
|
|
}
|
|
|
|
// Get the name from the first cell
|
|
const name = row.querySelector('td:first-child').textContent.trim();
|
|
|
|
// Get the main URL from the link in the URL cell (7th column)
|
|
const urlCell = row.querySelector('td:nth-child(7)');
|
|
const urlLink = urlCell.querySelector('a');
|
|
const mainUrl = urlLink ? urlLink.getAttribute('href') : urlCell.textContent.trim();
|
|
|
|
// Populate form
|
|
document.getElementById('edit_instance_id').value = id;
|
|
document.getElementById('edit_name').value = name;
|
|
document.getElementById('edit_main_url').value = mainUrl;
|
|
|
|
editInstanceModal.show();
|
|
} catch (error) {
|
|
console.error('Error preparing instance edit:', error);
|
|
alert('Error: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async function submitEditInstance() {
|
|
const form = document.getElementById('editInstanceForm');
|
|
const formData = new FormData(form);
|
|
const data = Object.fromEntries(formData.entries());
|
|
const id = document.getElementById('edit_instance_id').value;
|
|
|
|
try {
|
|
const response = await fetch(`/instances/${id}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': formData.get('csrf_token')
|
|
},
|
|
body: JSON.stringify({
|
|
name: data.name,
|
|
company: data.name.charAt(0).toUpperCase() + data.name.slice(1),
|
|
main_url: data.main_url
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`Failed to update instance: ${errorText}`);
|
|
}
|
|
|
|
editInstanceModal.hide();
|
|
location.reload(); // Refresh to show updated instance
|
|
} catch (error) {
|
|
console.error('Error updating instance:', error);
|
|
alert('Error: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async function deleteInstance(id) {
|
|
if (!confirm('Are you sure you want to delete this instance?')) return;
|
|
|
|
const form = document.getElementById('editInstanceForm');
|
|
const formData = new FormData(form);
|
|
|
|
try {
|
|
const response = await fetch(`/instances/${id}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'X-CSRF-Token': formData.get('csrf_token')
|
|
}
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to delete instance');
|
|
|
|
location.reload(); // Refresh to show updated list
|
|
} catch (error) {
|
|
alert('Error deleting instance: ' + error.message);
|
|
}
|
|
}
|
|
|
|
function updateInstanceFields() {
|
|
const url = document.getElementById('existing_url').value;
|
|
if (url) {
|
|
try {
|
|
const urlObj = new URL(url);
|
|
const hostname = urlObj.hostname;
|
|
const name = hostname.split('.')[0];
|
|
document.getElementById('existing_name').value = name;
|
|
} catch (e) {
|
|
console.error('Invalid URL:', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function submitAddExistingInstance() {
|
|
const form = document.getElementById('addExistingInstanceForm');
|
|
const formData = new FormData(form);
|
|
const data = Object.fromEntries(formData.entries());
|
|
|
|
try {
|
|
const response = await fetch('/instances/add', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': formData.get('csrf_token')
|
|
},
|
|
body: JSON.stringify({
|
|
name: data.name,
|
|
company: data.name.charAt(0).toUpperCase() + data.name.slice(1),
|
|
rooms_count: 0,
|
|
conversations_count: 0,
|
|
data_size: 0.0,
|
|
payment_plan: 'Basic',
|
|
main_url: data.url,
|
|
status: 'inactive'
|
|
})
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to add instance');
|
|
|
|
addExistingInstanceModal.hide();
|
|
location.reload(); // Refresh to show new instance
|
|
} catch (error) {
|
|
alert('Error adding instance: ' + error.message);
|
|
}
|
|
}
|
|
|
|
function showAuthModal(instanceUrl, instanceId) {
|
|
document.getElementById('instance_url').value = instanceUrl;
|
|
document.getElementById('instance_id').value = instanceId;
|
|
document.getElementById('authForm').reset();
|
|
authModal.show();
|
|
}
|
|
|
|
async function authenticateInstance() {
|
|
const form = document.getElementById('authForm');
|
|
const formData = new FormData(form);
|
|
const instanceUrl = formData.get('instance_url').replace(/\/+$/, ''); // Remove trailing slashes
|
|
const instanceId = formData.get('instance_id');
|
|
const email = formData.get('email');
|
|
const password = formData.get('password');
|
|
|
|
try {
|
|
console.log('Attempting login to:', `${instanceUrl}/api/admin/login`);
|
|
|
|
// First login to get token
|
|
const loginResponse = await fetch(`${instanceUrl}/api/admin/login`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json'
|
|
},
|
|
body: JSON.stringify({ email, password })
|
|
});
|
|
|
|
// Check content type
|
|
const contentType = loginResponse.headers.get('content-type');
|
|
if (!contentType || !contentType.includes('application/json')) {
|
|
console.error('Unexpected content type:', contentType);
|
|
const text = await loginResponse.text();
|
|
console.error('Response text:', text);
|
|
throw new Error(`Server returned non-JSON response (${contentType}). Please check if the instance is properly configured.`);
|
|
}
|
|
|
|
let responseData;
|
|
try {
|
|
responseData = await loginResponse.json();
|
|
console.log('Login response:', responseData);
|
|
} catch (e) {
|
|
console.error('Failed to parse JSON response:', e);
|
|
throw new Error('Invalid JSON response from server');
|
|
}
|
|
|
|
if (!loginResponse.ok) {
|
|
throw new Error(responseData.message || 'Login failed');
|
|
}
|
|
|
|
if (responseData.status !== 'success') {
|
|
throw new Error(responseData.message || 'Login failed');
|
|
}
|
|
|
|
const token = responseData.token;
|
|
if (!token) {
|
|
throw new Error('No token received from server');
|
|
}
|
|
|
|
// Then create management API key
|
|
const keyResponse = await fetch(`${instanceUrl}/api/admin/management-api-key`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
'Authorization': `Bearer ${token}`
|
|
},
|
|
body: JSON.stringify({
|
|
name: `Connection from ${window.location.hostname}`
|
|
})
|
|
});
|
|
|
|
// Check content type for key response
|
|
const keyContentType = keyResponse.headers.get('content-type');
|
|
if (!keyContentType || !keyContentType.includes('application/json')) {
|
|
console.error('Unexpected content type for key response:', keyContentType);
|
|
const text = await keyResponse.text();
|
|
console.error('Key response text:', text);
|
|
throw new Error(`Server returned non-JSON response for API key (${keyContentType})`);
|
|
}
|
|
|
|
let keyData;
|
|
try {
|
|
keyData = await keyResponse.json();
|
|
console.log('API key response:', keyData);
|
|
} catch (e) {
|
|
console.error('Failed to parse JSON response for API key:', e);
|
|
throw new Error('Invalid JSON response from server for API key');
|
|
}
|
|
|
|
if (!keyResponse.ok) {
|
|
throw new Error(keyData.message || 'Failed to create API key');
|
|
}
|
|
|
|
const api_key = keyData.api_key;
|
|
if (!api_key) {
|
|
throw new Error('No API key received from server');
|
|
}
|
|
|
|
// Save the token to our database
|
|
const saveResponse = await fetch(`/instances/${instanceId}/save-token`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': formData.get('csrf_token')
|
|
},
|
|
body: JSON.stringify({ token: api_key })
|
|
});
|
|
|
|
let saveData;
|
|
try {
|
|
saveData = await saveResponse.json();
|
|
console.log('Save token response:', saveData);
|
|
} catch (e) {
|
|
console.error('Failed to parse JSON response for save token:', e);
|
|
throw new Error('Invalid JSON response from server for save token');
|
|
}
|
|
|
|
if (!saveResponse.ok) {
|
|
throw new Error(saveData.message || 'Failed to save token');
|
|
}
|
|
|
|
// Show success and refresh
|
|
authModal.hide();
|
|
location.reload();
|
|
} catch (error) {
|
|
console.error('Authentication error:', error);
|
|
alert('Error: ' + error.message);
|
|
}
|
|
}
|
|
|
|
function copyConnectionToken(button) {
|
|
const input = button.previousElementSibling;
|
|
input.select();
|
|
document.execCommand('copy');
|
|
|
|
// Show feedback
|
|
const originalText = button.innerHTML;
|
|
button.innerHTML = '<i class="fas fa-check"></i>';
|
|
setTimeout(() => {
|
|
button.innerHTML = originalText;
|
|
}, 2000);
|
|
}
|
|
|
|
function nextStepLaunchInstance() {
|
|
// Hide the initial modal
|
|
addInstanceModal.hide();
|
|
|
|
// Reset and show the steps modal
|
|
currentStep = 1;
|
|
updateStepDisplay();
|
|
launchStepsModal.show();
|
|
|
|
// Start verifying connections
|
|
verifyConnections();
|
|
}
|
|
|
|
function updateStepDisplay() {
|
|
// Hide all steps
|
|
document.querySelectorAll('.step-pane').forEach(pane => {
|
|
pane.classList.remove('active');
|
|
});
|
|
|
|
// Show current step
|
|
document.getElementById(`step${currentStep}`).classList.add('active');
|
|
|
|
// Update step indicators
|
|
document.querySelectorAll('.step-item').forEach(item => {
|
|
const step = parseInt(item.getAttribute('data-step'));
|
|
item.classList.toggle('active', step === currentStep);
|
|
item.classList.toggle('completed', step < currentStep);
|
|
});
|
|
|
|
// Get the modal footer
|
|
const modalFooter = document.getElementById('launchStepsFooter');
|
|
const prevButton = modalFooter.querySelector('.btn-secondary');
|
|
const nextButton = modalFooter.querySelector('.btn-primary');
|
|
|
|
// Always show the footer
|
|
modalFooter.style.display = 'block';
|
|
|
|
// Show/hide Previous button based on step
|
|
prevButton.style.display = currentStep === 1 ? 'none' : 'inline-block';
|
|
|
|
// Show/hide Next button based on step
|
|
if (currentStep === totalSteps) {
|
|
nextButton.style.display = 'none';
|
|
} else {
|
|
nextButton.style.display = 'inline-block';
|
|
nextButton.innerHTML = 'Next <i class="fas fa-arrow-right ms-1"></i>';
|
|
}
|
|
|
|
// If we're on step 4, get the next available port
|
|
if (currentStep === 4) {
|
|
getNextAvailablePort();
|
|
}
|
|
}
|
|
|
|
function nextStep() {
|
|
if (currentStep === 2 && !validateStep2()) {
|
|
return;
|
|
}
|
|
if (currentStep === 3 && !validateStep3()) {
|
|
return;
|
|
}
|
|
if (currentStep === 4 && !validateStep4()) {
|
|
return;
|
|
}
|
|
|
|
if (currentStep < totalSteps) {
|
|
currentStep++;
|
|
updateStepDisplay();
|
|
|
|
// Update review section if we're moving to the final step
|
|
if (currentStep === totalSteps) {
|
|
updateReviewSection();
|
|
}
|
|
}
|
|
}
|
|
|
|
function previousStep() {
|
|
if (currentStep > 1) {
|
|
currentStep--;
|
|
updateStepDisplay();
|
|
}
|
|
}
|
|
|
|
async function verifyConnections() {
|
|
// Verify Portainer connection
|
|
try {
|
|
const portainerResponse = await fetch('/api/admin/test-portainer-connection', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': document.querySelector('input[name="csrf_token"]').value
|
|
},
|
|
body: JSON.stringify({
|
|
url: '{{ portainer_settings.url if portainer_settings else "" }}',
|
|
api_key: '{{ portainer_settings.api_key if portainer_settings else "" }}'
|
|
})
|
|
});
|
|
|
|
const portainerStatus = document.getElementById('portainerStatus');
|
|
const portainerDetails = document.getElementById('portainerDetails');
|
|
|
|
if (portainerResponse.ok) {
|
|
portainerStatus.innerHTML = '<i class="fas fa-check-circle"></i> Connected';
|
|
portainerStatus.className = 'connection-status success';
|
|
portainerDetails.innerHTML = '<small class="text-success">Successfully connected to Portainer</small>';
|
|
} else {
|
|
const error = await portainerResponse.json();
|
|
portainerStatus.innerHTML = '<i class="fas fa-times-circle"></i> Failed';
|
|
portainerStatus.className = 'connection-status error';
|
|
portainerDetails.innerHTML = `<small class="text-danger">${error.error || 'Failed to connect to Portainer'}</small>`;
|
|
}
|
|
} catch (error) {
|
|
const portainerStatus = document.getElementById('portainerStatus');
|
|
const portainerDetails = document.getElementById('portainerDetails');
|
|
portainerStatus.innerHTML = '<i class="fas fa-times-circle"></i> Error';
|
|
portainerStatus.className = 'connection-status error';
|
|
portainerDetails.innerHTML = `<small class="text-danger">${error.message || 'Error checking Portainer connection'}</small>`;
|
|
}
|
|
|
|
// Verify NGINX connection
|
|
try {
|
|
const nginxResponse = await fetch('/api/admin/test-nginx-connection', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': document.querySelector('input[name="csrf_token"]').value
|
|
},
|
|
body: JSON.stringify({
|
|
url: '{{ nginx_settings.url if nginx_settings else "" }}',
|
|
username: '{{ nginx_settings.username if nginx_settings else "" }}',
|
|
password: '{{ nginx_settings.password if nginx_settings else "" }}'
|
|
})
|
|
});
|
|
|
|
const nginxStatus = document.getElementById('nginxStatus');
|
|
const nginxDetails = document.getElementById('nginxDetails');
|
|
|
|
if (nginxResponse.ok) {
|
|
nginxStatus.innerHTML = '<i class="fas fa-check-circle"></i> Connected';
|
|
nginxStatus.className = 'connection-status success';
|
|
nginxDetails.innerHTML = '<small class="text-success">Successfully connected to NGINX Proxy Manager</small>';
|
|
} else {
|
|
const error = await nginxResponse.json();
|
|
nginxStatus.innerHTML = '<i class="fas fa-times-circle"></i> Failed';
|
|
nginxStatus.className = 'connection-status error';
|
|
nginxDetails.innerHTML = `<small class="text-danger">${error.error || 'Failed to connect to NGINX Proxy Manager'}</small>`;
|
|
}
|
|
} catch (error) {
|
|
const nginxStatus = document.getElementById('nginxStatus');
|
|
const nginxDetails = document.getElementById('nginxDetails');
|
|
nginxStatus.innerHTML = '<i class="fas fa-times-circle"></i> Error';
|
|
nginxStatus.className = 'connection-status error';
|
|
nginxDetails.innerHTML = `<small class="text-danger">${error.message || 'Error checking NGINX connection'}</small>`;
|
|
}
|
|
}
|
|
|
|
// Repository Selection Functions
|
|
async function loadRepositories() {
|
|
const repoSelect = document.getElementById('giteaRepoSelect');
|
|
const branchSelect = document.getElementById('giteaBranchSelect');
|
|
|
|
try {
|
|
const gitSettings = JSON.parse('{{ git_settings|tojson|safe }}');
|
|
if (!gitSettings || !gitSettings.url || !gitSettings.token) {
|
|
throw new Error('No Git settings found. Please configure Git in the settings page.');
|
|
}
|
|
|
|
// Load repositories using the correct endpoint
|
|
const repoResponse = await fetch('/api/admin/list-gitea-repos', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
|
},
|
|
body: JSON.stringify({
|
|
url: gitSettings.url,
|
|
token: gitSettings.token
|
|
})
|
|
});
|
|
|
|
if (!repoResponse.ok) {
|
|
throw new Error('Failed to load repositories');
|
|
}
|
|
|
|
const data = await repoResponse.json();
|
|
|
|
if (data.repositories) {
|
|
repoSelect.innerHTML = '<option value="">Select a repository</option>' +
|
|
data.repositories.map(repo =>
|
|
`<option value="${repo.full_name}" ${repo.full_name === gitSettings.repo ? 'selected' : ''}>${repo.full_name}</option>`
|
|
).join('');
|
|
repoSelect.disabled = false;
|
|
|
|
// If we have a saved repository, load its branches
|
|
if (gitSettings.repo) {
|
|
loadBranches(gitSettings.repo);
|
|
}
|
|
} else {
|
|
repoSelect.innerHTML = '<option value="">No repositories found</option>';
|
|
repoSelect.disabled = true;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading repositories:', error);
|
|
repoSelect.innerHTML = `<option value="">Error: ${error.message}</option>`;
|
|
repoSelect.disabled = true;
|
|
}
|
|
}
|
|
|
|
async function loadBranches(repoId) {
|
|
const branchSelect = document.getElementById('giteaBranchSelect');
|
|
|
|
try {
|
|
const gitSettings = JSON.parse('{{ git_settings|tojson|safe }}');
|
|
if (!gitSettings || !gitSettings.url || !gitSettings.token) {
|
|
throw new Error('No Git settings found. Please configure Git in the settings page.');
|
|
}
|
|
|
|
const response = await fetch('/api/admin/list-gitea-branches', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
|
},
|
|
body: JSON.stringify({
|
|
url: gitSettings.url,
|
|
token: gitSettings.token,
|
|
repo: repoId
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load branches');
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.branches) {
|
|
branchSelect.innerHTML = '<option value="">Select a branch</option>' +
|
|
data.branches.map(branch =>
|
|
`<option value="${branch.name}" ${branch.name === 'master' ? 'selected' : ''}>${branch.name}</option>`
|
|
).join('');
|
|
branchSelect.disabled = false;
|
|
} else {
|
|
branchSelect.innerHTML = '<option value="">No branches found</option>';
|
|
branchSelect.disabled = true;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading branches:', error);
|
|
branchSelect.innerHTML = `<option value="">Error: ${error.message}</option>`;
|
|
branchSelect.disabled = true;
|
|
}
|
|
}
|
|
|
|
// Event Listeners for Repository Selection
|
|
document.getElementById('giteaRepoSelect').addEventListener('change', function() {
|
|
const repoId = this.value;
|
|
if (repoId) {
|
|
loadBranches(repoId);
|
|
} else {
|
|
document.getElementById('giteaBranchSelect').innerHTML = '<option value="">Select a repository first</option>';
|
|
document.getElementById('giteaBranchSelect').disabled = true;
|
|
}
|
|
});
|
|
|
|
// Load repositories when step 2 is shown
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const step2Content = document.getElementById('step2');
|
|
const observer = new MutationObserver(function(mutations) {
|
|
mutations.forEach(function(mutation) {
|
|
if (mutation.target.classList.contains('active')) {
|
|
loadRepositories();
|
|
}
|
|
});
|
|
});
|
|
|
|
observer.observe(step2Content, { attributes: true, attributeFilter: ['class'] });
|
|
});
|
|
|
|
// Function to get the next available port
|
|
async function getNextAvailablePort() {
|
|
try {
|
|
// Get all existing instances
|
|
const response = await fetch('/instances');
|
|
const text = await response.text();
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(text, 'text/html');
|
|
|
|
// Get all port numbers from the table
|
|
const portCells = doc.querySelectorAll('table tbody tr td:first-child');
|
|
const ports = Array.from(portCells)
|
|
.map(cell => {
|
|
const portText = cell.textContent.trim();
|
|
// Only parse if it's a number
|
|
return /^\d+$/.test(portText) ? parseInt(portText) : null;
|
|
})
|
|
.filter(port => port !== null)
|
|
.sort((a, b) => a - b); // Sort numerically
|
|
|
|
console.log('Found existing ports:', ports);
|
|
|
|
// Find the next available port
|
|
let nextPort = 10339; // Start from the next port after your highest
|
|
while (ports.includes(nextPort)) {
|
|
nextPort++;
|
|
}
|
|
|
|
console.log('Next available port:', nextPort);
|
|
|
|
// Set the suggested port
|
|
document.getElementById('port').value = nextPort;
|
|
} catch (error) {
|
|
console.error('Error getting next available port:', error);
|
|
// Default to 10339 if there's an error
|
|
document.getElementById('port').value = 10339;
|
|
}
|
|
}
|
|
|
|
// Function to validate step 2
|
|
function validateStep2() {
|
|
const repositorySelect = document.getElementById('giteaRepoSelect');
|
|
const branchSelect = document.getElementById('giteaBranchSelect');
|
|
|
|
if (!repositorySelect.value) {
|
|
alert('Please select a repository');
|
|
return false;
|
|
}
|
|
|
|
if (!branchSelect.value) {
|
|
alert('Please select a branch');
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Function to validate step 3
|
|
function validateStep3() {
|
|
const requiredFields = [
|
|
{ id: 'companyName', name: 'Company Name' },
|
|
{ id: 'streetAddress', name: 'Street Address' },
|
|
{ id: 'city', name: 'City' },
|
|
{ id: 'zipCode', name: 'ZIP Code' },
|
|
{ id: 'country', name: 'Country' },
|
|
{ id: 'email', name: 'Email' },
|
|
{ id: 'phone', name: 'Phone' }
|
|
];
|
|
|
|
const missingFields = requiredFields.filter(field => {
|
|
const element = document.getElementById(field.id);
|
|
return !element.value.trim();
|
|
});
|
|
|
|
if (missingFields.length > 0) {
|
|
const fieldNames = missingFields.map(field => field.name).join(', ');
|
|
alert(`Please fill in all required fields: ${fieldNames}`);
|
|
return false;
|
|
}
|
|
|
|
// Validate email format
|
|
const email = document.getElementById('email').value;
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
if (!emailRegex.test(email)) {
|
|
alert('Please enter a valid email address');
|
|
return false;
|
|
}
|
|
|
|
// Validate phone format (basic validation)
|
|
const phone = document.getElementById('phone').value;
|
|
const phoneRegex = /^[\d\s\-\+\(\)]{10,}$/;
|
|
if (!phoneRegex.test(phone)) {
|
|
alert('Please enter a valid phone number');
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Function to validate step 4
|
|
function validateStep4() {
|
|
const port = document.getElementById('port').value;
|
|
const webAddresses = document.querySelectorAll('.web-address');
|
|
|
|
// Validate port
|
|
if (!port || port < 1024 || port > 65535) {
|
|
alert('Please enter a valid port number between 1024 and 65535');
|
|
return false;
|
|
}
|
|
|
|
// Validate web addresses
|
|
if (webAddresses.length === 0) {
|
|
alert('Please add at least one web address');
|
|
return false;
|
|
}
|
|
|
|
// Basic domain validation for each address
|
|
const domainRegex = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9](?:\.[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9])*\.[a-zA-Z]{2,}$/;
|
|
for (const address of webAddresses) {
|
|
if (!address.value) {
|
|
alert('Please fill in all web addresses');
|
|
return false;
|
|
}
|
|
if (!domainRegex.test(address.value)) {
|
|
alert(`Please enter a valid domain name for: ${address.value}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Check for duplicate addresses
|
|
const addresses = Array.from(webAddresses).map(addr => addr.value.toLowerCase());
|
|
const uniqueAddresses = new Set(addresses);
|
|
if (addresses.length !== uniqueAddresses.size) {
|
|
alert('Please remove duplicate web addresses');
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Function to add a new web address input
|
|
function addWebAddress() {
|
|
const container = document.getElementById('webAddressesContainer');
|
|
const newAddress = document.createElement('div');
|
|
newAddress.className = 'input-group mb-2';
|
|
newAddress.innerHTML = `
|
|
<span class="input-group-text">https://</span>
|
|
<input type="text" class="form-control web-address" required placeholder="your-domain.com">
|
|
<button type="button" class="btn btn-outline-danger remove-address" onclick="removeWebAddress(this)">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
`;
|
|
container.appendChild(newAddress);
|
|
|
|
// Show remove buttons if there's more than one address
|
|
updateRemoveButtons();
|
|
}
|
|
|
|
// Function to remove a web address input
|
|
function removeWebAddress(button) {
|
|
button.closest('.input-group').remove();
|
|
updateRemoveButtons();
|
|
}
|
|
|
|
// Function to update remove buttons visibility
|
|
function updateRemoveButtons() {
|
|
const addresses = document.querySelectorAll('.web-address');
|
|
const removeButtons = document.querySelectorAll('.remove-address');
|
|
|
|
// Show remove buttons only if there's more than one address
|
|
removeButtons.forEach(button => {
|
|
button.style.display = addresses.length > 1 ? 'block' : 'none';
|
|
});
|
|
}
|
|
|
|
// Function to get all web addresses
|
|
function getAllWebAddresses() {
|
|
return Array.from(document.querySelectorAll('.web-address')).map(input => input.value);
|
|
}
|
|
|
|
// Function to update the review section
|
|
function updateReviewSection() {
|
|
// Get all the values
|
|
const repo = document.getElementById('giteaRepoSelect').value;
|
|
const branch = document.getElementById('giteaBranchSelect').value;
|
|
const company = document.getElementById('companyName').value;
|
|
const port = document.getElementById('port').value;
|
|
const webAddresses = Array.from(document.querySelectorAll('.web-address'))
|
|
.map(input => input.value)
|
|
.join(', ');
|
|
|
|
// Update the review section
|
|
document.getElementById('reviewRepo').textContent = repo;
|
|
document.getElementById('reviewBranch').textContent = branch;
|
|
document.getElementById('reviewCompany').textContent = company;
|
|
document.getElementById('reviewPort').textContent = port;
|
|
document.getElementById('reviewWebAddresses').textContent = webAddresses;
|
|
}
|
|
|
|
// Function to launch the instance
|
|
function launchInstance() {
|
|
// Collect all the data
|
|
const instanceData = {
|
|
repository: document.getElementById('giteaRepoSelect').value,
|
|
branch: document.getElementById('giteaBranchSelect').value,
|
|
company: {
|
|
name: document.getElementById('companyName').value,
|
|
industry: document.getElementById('industry').value,
|
|
streetAddress: document.getElementById('streetAddress').value,
|
|
city: document.getElementById('city').value,
|
|
state: document.getElementById('state').value,
|
|
zipCode: document.getElementById('zipCode').value,
|
|
country: document.getElementById('country').value,
|
|
website: document.getElementById('website').value,
|
|
email: document.getElementById('email').value,
|
|
phone: document.getElementById('phone').value,
|
|
description: document.getElementById('companyDescription').value
|
|
},
|
|
port: document.getElementById('port').value,
|
|
webAddresses: Array.from(document.querySelectorAll('.web-address')).map(input => input.value),
|
|
colors: {
|
|
primary: document.getElementById('primaryColor').value,
|
|
secondary: document.getElementById('secondaryColor').value
|
|
}
|
|
};
|
|
|
|
// Store the data in sessionStorage
|
|
sessionStorage.setItem('instanceLaunchData', JSON.stringify(instanceData));
|
|
|
|
// Close the modal
|
|
launchStepsModal.hide();
|
|
|
|
// Redirect to the launch progress page
|
|
window.location.href = '/instances/launch-progress';
|
|
}
|
|
</script>
|
|
{% endblock %} |