6 Commits
0.11 ... 0.11.2

12 changed files with 606 additions and 64 deletions

Binary file not shown.

View File

@@ -0,0 +1,46 @@
"""add portainer stack fields to instances
Revision ID: 9206bf87bb8e
Revises: add_quota_fields
Create Date: 2025-06-24 14:02:17.375785
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision = '9206bf87bb8e'
down_revision = 'add_quota_fields'
branch_labels = None
depends_on = None
def upgrade():
conn = op.get_bind()
# Check if columns already exist
result = conn.execute(text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'instances'
AND column_name IN ('portainer_stack_id', 'portainer_stack_name')
"""))
existing_columns = [row[0] for row in result.fetchall()]
# Add portainer stack columns if they don't exist
with op.batch_alter_table('instances', schema=None) as batch_op:
if 'portainer_stack_id' not in existing_columns:
batch_op.add_column(sa.Column('portainer_stack_id', sa.String(length=100), nullable=True))
if 'portainer_stack_name' not in existing_columns:
batch_op.add_column(sa.Column('portainer_stack_name', sa.String(length=100), nullable=True))
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('instances', schema=None) as batch_op:
batch_op.drop_column('portainer_stack_name')
batch_op.drop_column('portainer_stack_id')
# ### end Alembic commands ###

View File

@@ -20,10 +20,20 @@ def upgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_table('email_template') op.drop_table('email_template')
op.drop_table('notification') op.drop_table('notification')
# Check if columns already exist before adding them
connection = op.get_bind()
inspector = sa.inspect(connection)
existing_columns = [col['name'] for col in inspector.get_columns('instances')]
with op.batch_alter_table('instances', schema=None) as batch_op: with op.batch_alter_table('instances', schema=None) as batch_op:
if 'deployed_version' not in existing_columns:
batch_op.add_column(sa.Column('deployed_version', sa.String(length=50), nullable=True)) batch_op.add_column(sa.Column('deployed_version', sa.String(length=50), nullable=True))
if 'deployed_branch' not in existing_columns:
batch_op.add_column(sa.Column('deployed_branch', sa.String(length=100), nullable=True)) batch_op.add_column(sa.Column('deployed_branch', sa.String(length=100), nullable=True))
if 'latest_version' not in existing_columns:
batch_op.add_column(sa.Column('latest_version', sa.String(length=50), nullable=True)) batch_op.add_column(sa.Column('latest_version', sa.String(length=50), nullable=True))
if 'version_checked_at' not in existing_columns:
batch_op.add_column(sa.Column('version_checked_at', sa.DateTime(), nullable=True)) batch_op.add_column(sa.Column('version_checked_at', sa.DateTime(), nullable=True))
with op.batch_alter_table('room_file', schema=None) as batch_op: with op.batch_alter_table('room_file', schema=None) as batch_op:
@@ -37,10 +47,19 @@ def downgrade():
with op.batch_alter_table('room_file', schema=None) as batch_op: with op.batch_alter_table('room_file', schema=None) as batch_op:
batch_op.create_foreign_key(batch_op.f('fk_room_file_deleted_by_user'), 'user', ['deleted_by'], ['id']) batch_op.create_foreign_key(batch_op.f('fk_room_file_deleted_by_user'), 'user', ['deleted_by'], ['id'])
# Check if columns exist before dropping them
connection = op.get_bind()
inspector = sa.inspect(connection)
existing_columns = [col['name'] for col in inspector.get_columns('instances')]
with op.batch_alter_table('instances', schema=None) as batch_op: with op.batch_alter_table('instances', schema=None) as batch_op:
if 'version_checked_at' in existing_columns:
batch_op.drop_column('version_checked_at') batch_op.drop_column('version_checked_at')
if 'latest_version' in existing_columns:
batch_op.drop_column('latest_version') batch_op.drop_column('latest_version')
if 'deployed_branch' in existing_columns:
batch_op.drop_column('deployed_branch') batch_op.drop_column('deployed_branch')
if 'deployed_version' in existing_columns:
batch_op.drop_column('deployed_version') batch_op.drop_column('deployed_version')
op.create_table('notification', op.create_table('notification',

View File

@@ -528,6 +528,9 @@ class Instance(db.Model):
status = db.Column(db.String(20), nullable=False, default='inactive') status = db.Column(db.String(20), nullable=False, default='inactive')
status_details = db.Column(db.Text, nullable=True) status_details = db.Column(db.Text, nullable=True)
connection_token = db.Column(db.String(64), unique=True, nullable=True) connection_token = db.Column(db.String(64), unique=True, nullable=True)
# Portainer integration fields
portainer_stack_id = db.Column(db.String(100), nullable=True) # Portainer stack ID
portainer_stack_name = db.Column(db.String(100), nullable=True) # Portainer stack name
# Version tracking fields # Version tracking fields
deployed_version = db.Column(db.String(50), nullable=True) # Current deployed version (commit hash or tag) deployed_version = db.Column(db.String(50), nullable=True) # Current deployed version (commit hash or tag)
deployed_branch = db.Column(db.String(100), nullable=True) # Branch that was deployed deployed_branch = db.Column(db.String(100), nullable=True) # Branch that was deployed

View File

@@ -676,3 +676,53 @@ def update_pricing_plan_status(plan_id):
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@admin.route('/api/admin/pricing-plans', methods=['GET'])
@login_required
def get_pricing_plans():
"""Get all active pricing plans for instance launch"""
if not current_user.is_admin:
return jsonify({'error': 'Unauthorized'}), 403
try:
from models import PricingPlan
# Get all active pricing plans ordered by order_index
plans = PricingPlan.query.filter_by(is_active=True).order_by(PricingPlan.order_index).all()
plans_data = []
for plan in plans:
plans_data.append({
'id': plan.id,
'name': plan.name,
'description': plan.description,
'monthly_price': plan.monthly_price,
'annual_price': plan.annual_price,
'features': plan.features,
'button_text': plan.button_text,
'button_url': plan.button_url,
'is_popular': plan.is_popular,
'is_custom': plan.is_custom,
'is_active': plan.is_active,
'order_index': plan.order_index,
'room_quota': plan.room_quota,
'conversation_quota': plan.conversation_quota,
'storage_quota_gb': plan.storage_quota_gb,
'manager_quota': plan.manager_quota,
'admin_quota': plan.admin_quota,
'format_quota_display': {
'room_quota': plan.format_quota_display('room_quota'),
'conversation_quota': plan.format_quota_display('conversation_quota'),
'storage_quota_gb': plan.format_quota_display('storage_quota_gb'),
'manager_quota': plan.format_quota_display('manager_quota'),
'admin_quota': plan.format_quota_display('admin_quota')
}
})
return jsonify({
'success': True,
'plans': plans_data
})
except Exception as e:
return jsonify({'error': str(e)}), 500

View File

@@ -580,7 +580,8 @@ def get_version_info(current_user):
'git_branch': os.environ.get('GIT_BRANCH', 'unknown'), 'git_branch': os.environ.get('GIT_BRANCH', 'unknown'),
'deployed_at': os.environ.get('DEPLOYED_AT', 'unknown'), 'deployed_at': os.environ.get('DEPLOYED_AT', 'unknown'),
'ismaster': os.environ.get('ISMASTER', 'false'), 'ismaster': os.environ.get('ISMASTER', 'false'),
'port': os.environ.get('PORT', 'unknown') 'port': os.environ.get('PORT', 'unknown'),
'pricing_tier_name': os.environ.get('PRICING_TIER_NAME', 'unknown')
} }
return jsonify(version_info) return jsonify(version_info)
@@ -591,6 +592,7 @@ def get_version_info(current_user):
'app_version': 'unknown', 'app_version': 'unknown',
'git_commit': 'unknown', 'git_commit': 'unknown',
'git_branch': 'unknown', 'git_branch': 'unknown',
'deployed_at': 'unknown' 'deployed_at': 'unknown',
'pricing_tier_name': 'unknown'
}), 500 }), 500

View File

@@ -1090,13 +1090,9 @@ def save_instance():
if existing_instance: if existing_instance:
# Update existing instance # Update existing instance
existing_instance.port = data['port'] existing_instance.portainer_stack_id = data['stack_id']
existing_instance.domains = data['domains'] existing_instance.portainer_stack_name = data['stack_name']
existing_instance.stack_id = data['stack_id']
existing_instance.stack_name = data['stack_name']
existing_instance.status = data['status'] existing_instance.status = data['status']
existing_instance.repository = data['repository']
existing_instance.branch = data['branch']
existing_instance.deployed_version = data.get('deployed_version', 'unknown') existing_instance.deployed_version = data.get('deployed_version', 'unknown')
existing_instance.deployed_branch = data.get('deployed_branch', data['branch']) existing_instance.deployed_branch = data.get('deployed_branch', data['branch'])
existing_instance.version_checked_at = datetime.utcnow() existing_instance.version_checked_at = datetime.utcnow()
@@ -1107,13 +1103,9 @@ def save_instance():
'message': 'Instance data updated successfully', 'message': 'Instance data updated successfully',
'data': { 'data': {
'name': existing_instance.name, 'name': existing_instance.name,
'port': existing_instance.port, 'portainer_stack_id': existing_instance.portainer_stack_id,
'domains': existing_instance.domains, 'portainer_stack_name': existing_instance.portainer_stack_name,
'stack_id': existing_instance.stack_id,
'stack_name': existing_instance.stack_name,
'status': existing_instance.status, 'status': existing_instance.status,
'repository': existing_instance.repository,
'branch': existing_instance.branch,
'deployed_version': existing_instance.deployed_version, 'deployed_version': existing_instance.deployed_version,
'deployed_branch': existing_instance.deployed_branch 'deployed_branch': existing_instance.deployed_branch
} }
@@ -1126,14 +1118,11 @@ def save_instance():
rooms_count=0, rooms_count=0,
conversations_count=0, conversations_count=0,
data_size=0.0, data_size=0.0,
payment_plan='Basic', payment_plan=data.get('payment_plan', 'Basic'),
main_url=f"https://{data['domains'][0]}" if data['domains'] else f"http://localhost:{data['port']}", main_url=f"https://{data['domains'][0]}" if data['domains'] else f"http://localhost:{data['port']}",
status=data['status'], status=data['status'],
port=data['port'], portainer_stack_id=data['stack_id'],
stack_id=data['stack_id'], portainer_stack_name=data['stack_name'],
stack_name=data['stack_name'],
repository=data['repository'],
branch=data['branch'],
deployed_version=data.get('deployed_version', 'unknown'), deployed_version=data.get('deployed_version', 'unknown'),
deployed_branch=data.get('deployed_branch', data['branch']) deployed_branch=data.get('deployed_branch', data['branch'])
) )
@@ -1145,13 +1134,9 @@ def save_instance():
'message': 'Instance data saved successfully', 'message': 'Instance data saved successfully',
'data': { 'data': {
'name': instance.name, 'name': instance.name,
'port': instance.port, 'portainer_stack_id': instance.portainer_stack_id,
'domains': instance.domains, 'portainer_stack_name': instance.portainer_stack_name,
'stack_id': instance.stack_id,
'stack_name': instance.stack_name,
'status': instance.status, 'status': instance.status,
'repository': instance.repository,
'branch': instance.branch,
'deployed_version': instance.deployed_version, 'deployed_version': instance.deployed_version,
'deployed_branch': instance.deployed_branch 'deployed_branch': instance.deployed_branch
} }

View File

@@ -593,6 +593,9 @@ async function startLaunch(data) {
// Save instance data // Save instance data
await updateStep(10, 'Saving Instance Data', 'Storing instance information...'); await updateStep(10, 'Saving Instance Data', 'Storing instance information...');
try { try {
// Get the launch data from sessionStorage to access pricing tier info
const launchData = JSON.parse(sessionStorage.getItem('instanceLaunchData') || '{}');
const instanceData = { const instanceData = {
name: data.instanceName, name: data.instanceName,
port: data.port, port: data.port,
@@ -603,7 +606,8 @@ async function startLaunch(data) {
repository: data.repository, repository: data.repository,
branch: data.branch, branch: data.branch,
deployed_version: dockerComposeResult.latest_tag || dockerComposeResult.commit_hash || 'unknown', deployed_version: dockerComposeResult.latest_tag || dockerComposeResult.commit_hash || 'unknown',
deployed_branch: data.branch deployed_branch: data.branch,
payment_plan: launchData.pricingTier?.name || 'Basic' // Use the selected pricing tier name
}; };
console.log('Saving instance data:', instanceData); console.log('Saving instance data:', instanceData);
const saveResult = await saveInstanceData(instanceData); const saveResult = await saveInstanceData(instanceData);
@@ -2046,28 +2050,8 @@ async function saveInstanceData(instanceData) {
if (existingInstance) { if (existingInstance) {
console.log('Instance already exists:', instanceData.port); console.log('Instance already exists:', instanceData.port);
return { // Update existing instance with new data
success: true, const updateResponse = await fetch('/api/admin/save-instance', {
data: {
name: instanceData.port,
company: 'loading...',
rooms_count: 0,
conversations_count: 0,
data_size: 0.0,
payment_plan: 'Basic',
main_url: `https://${instanceData.domains[0]}`,
status: 'inactive',
port: instanceData.port,
stack_id: instanceData.stack_id || '', // Use empty string if null
stack_name: instanceData.stack_name,
repository: instanceData.repository,
branch: instanceData.branch
}
};
}
// If instance doesn't exist, create it
const response = await fetch('/instances/add', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -2075,18 +2059,53 @@ async function saveInstanceData(instanceData) {
}, },
body: JSON.stringify({ body: JSON.stringify({
name: instanceData.port, name: instanceData.port,
company: 'loading...',
rooms_count: 0,
conversations_count: 0,
data_size: 0.0,
payment_plan: 'Basic',
main_url: `https://${instanceData.domains[0]}`,
status: 'inactive',
port: instanceData.port, port: instanceData.port,
stack_id: instanceData.stack_id || '', // Use empty string if null domains: instanceData.domains,
stack_id: instanceData.stack_id || '',
stack_name: instanceData.stack_name, stack_name: instanceData.stack_name,
status: instanceData.status,
repository: instanceData.repository, repository: instanceData.repository,
branch: instanceData.branch branch: instanceData.branch,
deployed_version: instanceData.deployed_version,
deployed_branch: instanceData.deployed_branch,
payment_plan: instanceData.payment_plan || 'Basic'
})
});
if (!updateResponse.ok) {
const errorText = await updateResponse.text();
console.error('Error updating instance:', errorText);
throw new Error(`Failed to update instance data: ${updateResponse.status} ${updateResponse.statusText}`);
}
const updateResult = await updateResponse.json();
console.log('Instance updated:', updateResult);
return {
success: true,
data: updateResult.data
};
}
// If instance doesn't exist, create it
const response = await fetch('/api/admin/save-instance', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
name: instanceData.port,
port: instanceData.port,
domains: instanceData.domains,
stack_id: instanceData.stack_id || '',
stack_name: instanceData.stack_name,
status: instanceData.status,
repository: instanceData.repository,
branch: instanceData.branch,
deployed_version: instanceData.deployed_version,
deployed_branch: instanceData.deployed_branch,
payment_plan: instanceData.payment_plan || 'Basic'
}) })
}); });
@@ -2599,6 +2618,32 @@ async function checkStackExists(stackName) {
// Add new function to deploy stack // Add new function to deploy stack
async function deployStack(dockerComposeContent, stackName, port) { async function deployStack(dockerComposeContent, stackName, port) {
try { try {
// Get the launch data from sessionStorage to access pricing tier info
const launchData = JSON.parse(sessionStorage.getItem('instanceLaunchData'));
// Fetch the pricing tier details to get the actual quota values
let pricingTierDetails = null;
if (launchData?.pricingTier?.id) {
try {
const pricingResponse = await fetch(`/api/admin/pricing-plans/${launchData.pricingTier.id}`, {
method: 'GET',
headers: {
'Accept': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
}
});
if (pricingResponse.ok) {
const pricingData = await pricingResponse.json();
if (pricingData.success) {
pricingTierDetails = pricingData.plan;
}
}
} catch (error) {
console.warn('Failed to fetch pricing tier details:', error);
}
}
// First, attempt to deploy the stack // First, attempt to deploy the stack
const response = await fetch('/api/admin/deploy-stack', { const response = await fetch('/api/admin/deploy-stack', {
method: 'POST', method: 'POST',
@@ -2633,6 +2678,35 @@ async function deployStack(dockerComposeContent, stackName, port) {
{ {
name: 'DEPLOYED_AT', name: 'DEPLOYED_AT',
value: new Date().toISOString() value: new Date().toISOString()
},
// Pricing tier environment variables with actual quota values
{
name: 'PRICING_TIER_ID',
value: launchData?.pricingTier?.id?.toString() || '0'
},
{
name: 'PRICING_TIER_NAME',
value: launchData?.pricingTier?.name || 'Unknown'
},
{
name: 'ROOM_QUOTA',
value: pricingTierDetails?.room_quota?.toString() || '0'
},
{
name: 'CONVERSATION_QUOTA',
value: pricingTierDetails?.conversation_quota?.toString() || '0'
},
{
name: 'STORAGE_QUOTA_GB',
value: pricingTierDetails?.storage_quota_gb?.toString() || '0'
},
{
name: 'MANAGER_QUOTA',
value: pricingTierDetails?.manager_quota?.toString() || '0'
},
{
name: 'ADMIN_QUOTA',
value: pricingTierDetails?.admin_quota?.toString() || '0'
} }
] ]
}) })

View File

@@ -130,6 +130,33 @@
<div class="company-value" id="company-description">Loading...</div> <div class="company-value" id="company-description">Loading...</div>
</div> </div>
</div> </div>
<div class="mb-3">
<div class="d-flex">
<div class="text-muted me-2" style="min-width: 120px;">Version:</div>
<div class="company-value" id="instance-version-value">Loading...</div>
</div>
</div>
<div class="mb-3">
<div class="d-flex">
<div class="text-muted me-2" style="min-width: 120px;">Payment Plan:</div>
<div class="company-value" id="instance-payment-plan-value">Loading...</div>
</div>
</div>
<div class="mb-3">
<div class="d-flex">
<div class="text-muted me-2" style="min-width: 120px;">Portainer Stack:</div>
<div class="company-value">
{% if instance.portainer_stack_name %}
<span class="badge bg-primary">{{ instance.portainer_stack_name }}</span>
{% if instance.portainer_stack_id %}
<small class="text-muted d-block mt-1">ID: {{ instance.portainer_stack_id }}</small>
{% endif %}
{% else %}
<span class="text-muted">Not set</span>
{% endif %}
</div>
</div>
</div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<h5 class="mb-3">Contact Information</h5> <h5 class="mb-3">Contact Information</h5>
@@ -1569,5 +1596,46 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} }
}); });
// Function to fetch version and payment plan info
async function fetchInstanceVersionAndPlan() {
const versionEl = document.getElementById('instance-version-value');
const planEl = document.getElementById('instance-payment-plan-value');
versionEl.textContent = 'Loading...';
planEl.textContent = 'Loading...';
try {
// Get JWT token
const tokenResponse = await fetch(`{{ instance.main_url }}/api/admin/management-token`, {
method: 'POST',
headers: {
'X-API-Key': '{{ instance.connection_token }}',
'Accept': 'application/json'
}
});
if (!tokenResponse.ok) throw new Error('Failed to get management token');
const tokenData = await tokenResponse.json();
if (!tokenData.token) throw new Error('No token received');
// Fetch version info
const response = await fetch(`{{ instance.main_url }}/api/admin/version-info`, {
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${tokenData.token}`
}
});
if (!response.ok) throw new Error('Failed to fetch version info');
const data = await response.json();
versionEl.textContent = data.app_version || 'Unknown';
planEl.textContent = data.pricing_tier_name || 'Unknown';
} catch (error) {
versionEl.textContent = 'Error';
planEl.textContent = 'Error';
console.error('Error fetching version/plan info:', error);
}
}
document.addEventListener('DOMContentLoaded', function() {
// ... existing code ...
fetchInstanceVersionAndPlan();
});
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -49,6 +49,54 @@
.badge.bg-orange:hover { .badge.bg-orange:hover {
background-color: #e55a00 !important; background-color: #e55a00 !important;
} }
/* Pricing tier selection styles */
.pricing-card {
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.pricing-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-color: var(--primary-color);
}
.pricing-card.selected {
border-color: var(--primary-color);
background-color: rgba(22, 118, 123, 0.05);
box-shadow: 0 4px 12px rgba(22, 118, 123, 0.2);
}
.pricing-card.selected::after {
content: '✓';
position: absolute;
top: 10px;
right: 10px;
background-color: var(--primary-color);
color: white;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
}
.pricing-card.border-primary {
border-color: var(--primary-color) !important;
}
.quota-info {
font-size: 0.75rem;
}
.features {
text-align: left;
}
</style> </style>
{% endblock %} {% endblock %}
@@ -131,6 +179,7 @@
<th>Main URL</th> <th>Main URL</th>
<th>Status</th> <th>Status</th>
<th>Version</th> <th>Version</th>
<th>Portainer Stack</th>
<th>Connection Token</th> <th>Connection Token</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
@@ -143,7 +192,7 @@
<td>{{ instance.rooms_count }}</td> <td>{{ instance.rooms_count }}</td>
<td>{{ instance.conversations_count }}</td> <td>{{ instance.conversations_count }}</td>
<td>{{ "%.1f"|format(instance.data_size) }} GB</td> <td>{{ "%.1f"|format(instance.data_size) }} GB</td>
<td>{{ instance.payment_plan }}</td> <td id="payment-plan-{{ instance.id }}">{{ instance.payment_plan }}</td>
<td> <td>
<a href="{{ instance.main_url }}" <a href="{{ instance.main_url }}"
target="_blank" target="_blank"
@@ -172,6 +221,16 @@
<span class="badge bg-secondary version-badge">unknown</span> <span class="badge bg-secondary version-badge">unknown</span>
{% endif %} {% endif %}
</td> </td>
<td>
{% if instance.portainer_stack_name %}
<span class="badge bg-primary" data-bs-toggle="tooltip"
title="Stack ID: {{ instance.portainer_stack_id or 'N/A' }}">
{{ instance.portainer_stack_name }}
</span>
{% else %}
<span class="badge bg-secondary">Not set</span>
{% endif %}
</td>
<td> <td>
{% if instance.connection_token %} {% if instance.connection_token %}
<span class="badge bg-success" data-bs-toggle="tooltip" title="Instance is authenticated"> <span class="badge bg-success" data-bs-toggle="tooltip" title="Instance is authenticated">
@@ -246,6 +305,10 @@
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<!-- Hidden fields for pricing tier selection -->
<input type="hidden" id="selectedPricingTierId" value="">
<input type="hidden" id="selectedPricingTierName" value="">
<!-- Steps Navigation --> <!-- Steps Navigation -->
<div class="d-flex justify-content-between mb-4"> <div class="d-flex justify-content-between mb-4">
<div class="step-item active" data-step="1"> <div class="step-item active" data-step="1">
@@ -270,6 +333,10 @@
</div> </div>
<div class="step-item" data-step="6"> <div class="step-item" data-step="6">
<div class="step-circle">6</div> <div class="step-circle">6</div>
<div class="step-label">Pricing Tier</div>
</div>
<div class="step-item" data-step="7">
<div class="step-circle">7</div>
<div class="step-label">Launch</div> <div class="step-label">Launch</div>
</div> </div>
</div> </div>
@@ -479,8 +546,30 @@
</div> </div>
</div> </div>
<!-- Step 6 --> <!-- Step 6: Pricing Tier Selection -->
<div class="step-pane" id="step6"> <div class="step-pane" id="step6">
<div class="step-content">
<div class="card">
<div class="card-body">
<h5 class="card-title mb-4">Select Pricing Tier</h5>
<p class="text-muted mb-4">Choose the pricing tier that best fits your needs. This will determine the resource limits for your instance.</p>
<div id="pricingTiersContainer" class="row">
<!-- Pricing tiers will be loaded here -->
<div class="col-12 text-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading pricing tiers...</span>
</div>
<p class="mt-2">Loading available pricing tiers...</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Step 7 -->
<div class="step-pane" id="step7">
<div class="text-center"> <div class="text-center">
<i class="fas fa-rocket fa-4x mb-4" style="color: var(--primary-color);"></i> <i class="fas fa-rocket fa-4x mb-4" style="color: var(--primary-color);"></i>
<h4>Ready to Launch!</h4> <h4>Ready to Launch!</h4>
@@ -494,6 +583,7 @@
<p><strong>Repository:</strong> <span id="reviewRepo"></span></p> <p><strong>Repository:</strong> <span id="reviewRepo"></span></p>
<p><strong>Branch:</strong> <span id="reviewBranch"></span></p> <p><strong>Branch:</strong> <span id="reviewBranch"></span></p>
<p><strong>Company:</strong> <span id="reviewCompany"></span></p> <p><strong>Company:</strong> <span id="reviewCompany"></span></p>
<p><strong>Pricing Tier:</strong> <span id="reviewPricingTier"></span></p>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<p><strong>Port:</strong> <span id="reviewPort"></span></p> <p><strong>Port:</strong> <span id="reviewPort"></span></p>
@@ -723,7 +813,7 @@ let launchStepsModal;
let currentStep = 1; let currentStep = 1;
// Update the total number of steps // Update the total number of steps
const totalSteps = 6; const totalSteps = 7;
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
addInstanceModal = new bootstrap.Modal(document.getElementById('addInstanceModal')); addInstanceModal = new bootstrap.Modal(document.getElementById('addInstanceModal'));
@@ -998,6 +1088,7 @@ function compareSemanticVersions(currentVersion, latestVersion) {
async function fetchVersionInfo(instanceUrl, instanceId) { async function fetchVersionInfo(instanceUrl, instanceId) {
const row = document.querySelector(`[data-instance-id="${instanceId}"]`).closest('tr'); const row = document.querySelector(`[data-instance-id="${instanceId}"]`).closest('tr');
const versionCell = row.querySelector('td:nth-child(9)'); // Version column (adjusted after removing branch) const versionCell = row.querySelector('td:nth-child(9)'); // Version column (adjusted after removing branch)
const paymentPlanCell = row.querySelector('td:nth-child(6)'); // Payment Plan column
// Show loading state // Show loading state
if (versionCell) { if (versionCell) {
@@ -1033,6 +1124,25 @@ async function fetchVersionInfo(instanceUrl, instanceId) {
const data = await response.json(); const data = await response.json();
console.log('Received version data:', data); console.log('Received version data:', data);
// Update payment plan cell with pricing tier name
if (paymentPlanCell) {
const pricingTierName = data.pricing_tier_name || 'unknown';
if (pricingTierName !== 'unknown') {
paymentPlanCell.innerHTML = `
<span class="badge bg-info" data-bs-toggle="tooltip" title="Pricing Tier: ${pricingTierName}">
<i class="fas fa-tag me-1"></i>${pricingTierName}
</span>`;
// Add tooltip for payment plan
const paymentPlanBadge = paymentPlanCell.querySelector('[data-bs-toggle="tooltip"]');
if (paymentPlanBadge) {
new bootstrap.Tooltip(paymentPlanBadge);
}
} else {
paymentPlanCell.innerHTML = '<span class="badge bg-secondary">unknown</span>';
}
}
// Update version cell // Update version cell
if (versionCell) { if (versionCell) {
const appVersion = data.app_version || 'unknown'; const appVersion = data.app_version || 'unknown';
@@ -1130,11 +1240,23 @@ async function fetchVersionInfo(instanceUrl, instanceId) {
</span>`; </span>`;
} }
if (paymentPlanCell) {
paymentPlanCell.innerHTML = `
<span class="text-warning" data-bs-toggle="tooltip" title="Error: ${error.message}">
<i class="fas fa-exclamation-triangle"></i> Error
</span>`;
}
// Add tooltips for error states // Add tooltips for error states
const errorBadge = versionCell?.querySelector('[data-bs-toggle="tooltip"]'); const errorBadge = versionCell?.querySelector('[data-bs-toggle="tooltip"]');
if (errorBadge) { if (errorBadge) {
new bootstrap.Tooltip(errorBadge); new bootstrap.Tooltip(errorBadge);
} }
const paymentPlanErrorBadge = paymentPlanCell?.querySelector('[data-bs-toggle="tooltip"]');
if (paymentPlanErrorBadge) {
new bootstrap.Tooltip(paymentPlanErrorBadge);
}
} }
} }
@@ -1650,6 +1772,11 @@ function updateStepDisplay() {
if (currentStep === 4) { if (currentStep === 4) {
getNextAvailablePort(); getNextAvailablePort();
} }
// If we're on step 6, load pricing tiers
if (currentStep === 6) {
loadPricingTiers();
}
} }
function nextStep() { function nextStep() {
@@ -1662,6 +1789,9 @@ function nextStep() {
if (currentStep === 4 && !validateStep4()) { if (currentStep === 4 && !validateStep4()) {
return; return;
} }
if (currentStep === 6 && !validateStep6()) {
return;
}
if (currentStep < totalSteps) { if (currentStep < totalSteps) {
currentStep++; currentStep++;
@@ -2066,6 +2196,7 @@ function updateReviewSection() {
const webAddresses = Array.from(document.querySelectorAll('.web-address')) const webAddresses = Array.from(document.querySelectorAll('.web-address'))
.map(input => input.value) .map(input => input.value)
.join(', '); .join(', ');
const pricingTier = document.getElementById('selectedPricingTierName').value;
// Update the review section // Update the review section
document.getElementById('reviewRepo').textContent = repo; document.getElementById('reviewRepo').textContent = repo;
@@ -2073,6 +2204,7 @@ function updateReviewSection() {
document.getElementById('reviewCompany').textContent = company; document.getElementById('reviewCompany').textContent = company;
document.getElementById('reviewPort').textContent = port; document.getElementById('reviewPort').textContent = port;
document.getElementById('reviewWebAddresses').textContent = webAddresses; document.getElementById('reviewWebAddresses').textContent = webAddresses;
document.getElementById('reviewPricingTier').textContent = pricingTier || 'Not selected';
} }
// Function to launch the instance // Function to launch the instance
@@ -2099,6 +2231,10 @@ function launchInstance() {
colors: { colors: {
primary: document.getElementById('primaryColor').value, primary: document.getElementById('primaryColor').value,
secondary: document.getElementById('secondaryColor').value secondary: document.getElementById('secondaryColor').value
},
pricingTier: {
id: document.getElementById('selectedPricingTierId').value,
name: document.getElementById('selectedPricingTierName').value
} }
}; };
@@ -2240,5 +2376,164 @@ async function refreshLatestVersion() {
console.log('Manual refresh of latest version requested'); console.log('Manual refresh of latest version requested');
await fetchLatestVersion(); await fetchLatestVersion();
} }
// Function to load pricing tiers
async function loadPricingTiers() {
const container = document.getElementById('pricingTiersContainer');
try {
const response = await fetch('/api/admin/pricing-plans', {
method: 'GET',
headers: {
'Accept': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
}
});
if (!response.ok) {
throw new Error('Failed to load pricing tiers');
}
const data = await response.json();
if (data.plans && data.plans.length > 0) {
container.innerHTML = data.plans.map(plan => `
<div class="col-md-6 col-lg-4 mb-3">
<div class="card pricing-card h-100 ${plan.is_popular ? 'border-primary' : ''}"
onclick="selectPricingTier(${plan.id}, '${plan.name}')">
<div class="card-body text-center">
${plan.is_popular ? '<div class="badge bg-primary mb-2">Most Popular</div>' : ''}
<h5 class="card-title">${plan.name}</h5>
${plan.description ? `<p class="text-muted small mb-3">${plan.description}</p>` : ''}
<div class="pricing mb-3">
${plan.is_custom ?
'<span class="h4 text-primary">Custom Pricing</span>' :
`<span class="h4 text-primary">€${plan.monthly_price}</span><span class="text-muted">/month</span>`
}
</div>
<div class="quota-info small text-muted mb-3">
<div class="row">
<div class="col-6">
<i class="fas fa-door-open me-1"></i>${plan.format_quota_display.room_quota}<br>
<i class="fas fa-comments me-1"></i>${plan.format_quota_display.conversation_quota}<br>
<i class="fas fa-hdd me-1"></i>${plan.format_quota_display.storage_quota_gb}
</div>
<div class="col-6">
<i class="fas fa-user-tie me-1"></i>${plan.format_quota_display.manager_quota}<br>
<i class="fas fa-user-shield me-1"></i>${plan.format_quota_display.admin_quota}
</div>
</div>
</div>
<div class="features small">
${plan.features.slice(0, 3).map(feature => `<div>✓ ${feature}</div>`).join('')}
${plan.features.length > 3 ? `<div class="text-muted">+${plan.features.length - 3} more features</div>` : ''}
</div>
</div>
</div>
</div>
`).join('');
} else {
container.innerHTML = `
<div class="col-12 text-center">
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
No pricing tiers available. Please contact your administrator.
</div>
</div>
`;
}
} catch (error) {
console.error('Error loading pricing tiers:', error);
container.innerHTML = `
<div class="col-12 text-center">
<div class="alert alert-danger">
<i class="fas fa-exclamation-triangle me-2"></i>
Error loading pricing tiers: ${error.message}
</div>
</div>
`;
}
}
// Function to select a pricing tier
function selectPricingTier(planId, planName) {
// Remove selection from all cards
document.querySelectorAll('.pricing-card').forEach(card => {
card.classList.remove('selected');
});
// Add selection to clicked card
event.currentTarget.classList.add('selected');
// Store the selection
document.getElementById('selectedPricingTierId').value = planId;
document.getElementById('selectedPricingTierName').value = planName;
// Enable next button if not already enabled
const nextButton = document.querySelector('#launchStepsFooter .btn-primary');
if (nextButton.disabled) {
nextButton.disabled = false;
}
}
// Function to validate step 6 (pricing tier selection)
function validateStep6() {
const selectedTierId = document.getElementById('selectedPricingTierId').value;
if (!selectedTierId) {
alert('Please select a pricing tier before proceeding.');
return false;
}
return true;
}
document.addEventListener('DOMContentLoaded', function() {
// For each instance row, fetch the payment plan from the instance API
document.querySelectorAll('[data-instance-id]').forEach(function(badge) {
const instanceId = badge.getAttribute('data-instance-id');
const token = badge.getAttribute('data-token');
const row = badge.closest('tr');
const urlCell = row.querySelector('td:nth-child(7) a');
const paymentPlanCell = document.getElementById('payment-plan-' + instanceId);
if (!urlCell || !token || !paymentPlanCell) return;
const instanceUrl = urlCell.getAttribute('href');
// Get management token
fetch(instanceUrl.replace(/\/$/, '') + '/api/admin/management-token', {
method: 'POST',
headers: {
'X-API-Key': token,
'Accept': 'application/json'
}
})
.then(res => res.json())
.then(data => {
if (!data.token) throw new Error('No management token');
// Fetch version info (which includes pricing_tier_name)
return fetch(instanceUrl.replace(/\/$/, '') + '/api/admin/version-info', {
headers: {
'Authorization': 'Bearer ' + data.token,
'Accept': 'application/json'
}
});
})
.then(res => res.json())
.then(data => {
if (data.pricing_tier_name) {
paymentPlanCell.textContent = data.pricing_tier_name;
} else {
paymentPlanCell.textContent = 'Unknown';
}
})
.catch(err => {
paymentPlanCell.textContent = 'Unknown';
});
});
});
</script> </script>
{% endblock %} {% endblock %}