12 Commits

Author SHA1 Message Date
5b598f2966 finalized update feature 2025-06-25 15:27:49 +02:00
77032062a1 better update? 2025-06-25 15:07:35 +02:00
81675af837 Update launch_progress.js 2025-06-25 14:58:26 +02:00
0a2cddf122 Update start and better volume names 2025-06-25 14:53:32 +02:00
56d94a06ce Better stack name 2025-06-25 14:21:22 +02:00
de3880e880 fixed help articles 2025-06-25 13:34:43 +02:00
0466b11c71 delete functionality on instances page 2025-06-25 11:58:37 +02:00
e519dc3a8b delete old files 2025-06-25 11:39:14 +02:00
ac9f002365 color system on public pages 2025-06-25 11:38:12 +02:00
8de74827f2 split js and css on instance detail 2025-06-25 11:16:10 +02:00
81552bc5ec split css and js on instances 2025-06-25 11:09:56 +02:00
490bc05a9e better delete modal 2025-06-25 10:40:05 +02:00
34 changed files with 5141 additions and 4163 deletions

View File

@@ -1,274 +0,0 @@
{
"openapi": "3.1.0",
"info": {
"title": "Nginx Proxy Manager API",
"version": "2.x.x"
},
"servers": [
{
"url": "http://127.0.0.1:81/api"
}
],
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
}
},
"paths": {
"/": {
"get": {
"$ref": "./paths/get.json"
}
},
"/audit-log": {
"get": {
"$ref": "./paths/audit-log/get.json"
}
},
"/nginx/access-lists": {
"get": {
"$ref": "./paths/nginx/access-lists/get.json"
},
"post": {
"$ref": "./paths/nginx/access-lists/post.json"
}
},
"/nginx/access-lists/{listID}": {
"get": {
"$ref": "./paths/nginx/access-lists/listID/get.json"
},
"put": {
"$ref": "./paths/nginx/access-lists/listID/put.json"
},
"delete": {
"$ref": "./paths/nginx/access-lists/listID/delete.json"
}
},
"/nginx/certificates": {
"get": {
"$ref": "./paths/nginx/certificates/get.json"
},
"post": {
"$ref": "./paths/nginx/certificates/post.json"
}
},
"/nginx/certificates/validate": {
"post": {
"$ref": "./paths/nginx/certificates/validate/post.json"
}
},
"/nginx/certificates/test-http": {
"get": {
"$ref": "./paths/nginx/certificates/test-http/get.json"
}
},
"/nginx/certificates/{certID}": {
"get": {
"$ref": "./paths/nginx/certificates/certID/get.json"
},
"delete": {
"$ref": "./paths/nginx/certificates/certID/delete.json"
}
},
"/nginx/certificates/{certID}/download": {
"get": {
"$ref": "./paths/nginx/certificates/certID/download/get.json"
}
},
"/nginx/certificates/{certID}/renew": {
"post": {
"$ref": "./paths/nginx/certificates/certID/renew/post.json"
}
},
"/nginx/certificates/{certID}/upload": {
"post": {
"$ref": "./paths/nginx/certificates/certID/upload/post.json"
}
},
"/nginx/proxy-hosts": {
"get": {
"$ref": "./paths/nginx/proxy-hosts/get.json"
},
"post": {
"$ref": "./paths/nginx/proxy-hosts/post.json"
}
},
"/nginx/proxy-hosts/{hostID}": {
"get": {
"$ref": "./paths/nginx/proxy-hosts/hostID/get.json"
},
"put": {
"$ref": "./paths/nginx/proxy-hosts/hostID/put.json"
},
"delete": {
"$ref": "./paths/nginx/proxy-hosts/hostID/delete.json"
}
},
"/nginx/proxy-hosts/{hostID}/enable": {
"post": {
"$ref": "./paths/nginx/proxy-hosts/hostID/enable/post.json"
}
},
"/nginx/proxy-hosts/{hostID}/disable": {
"post": {
"$ref": "./paths/nginx/proxy-hosts/hostID/disable/post.json"
}
},
"/nginx/redirection-hosts": {
"get": {
"$ref": "./paths/nginx/redirection-hosts/get.json"
},
"post": {
"$ref": "./paths/nginx/redirection-hosts/post.json"
}
},
"/nginx/redirection-hosts/{hostID}": {
"get": {
"$ref": "./paths/nginx/redirection-hosts/hostID/get.json"
},
"put": {
"$ref": "./paths/nginx/redirection-hosts/hostID/put.json"
},
"delete": {
"$ref": "./paths/nginx/redirection-hosts/hostID/delete.json"
}
},
"/nginx/redirection-hosts/{hostID}/enable": {
"post": {
"$ref": "./paths/nginx/redirection-hosts/hostID/enable/post.json"
}
},
"/nginx/redirection-hosts/{hostID}/disable": {
"post": {
"$ref": "./paths/nginx/redirection-hosts/hostID/disable/post.json"
}
},
"/nginx/dead-hosts": {
"get": {
"$ref": "./paths/nginx/dead-hosts/get.json"
},
"post": {
"$ref": "./paths/nginx/dead-hosts/post.json"
}
},
"/nginx/dead-hosts/{hostID}": {
"get": {
"$ref": "./paths/nginx/dead-hosts/hostID/get.json"
},
"put": {
"$ref": "./paths/nginx/dead-hosts/hostID/put.json"
},
"delete": {
"$ref": "./paths/nginx/dead-hosts/hostID/delete.json"
}
},
"/nginx/dead-hosts/{hostID}/enable": {
"post": {
"$ref": "./paths/nginx/dead-hosts/hostID/enable/post.json"
}
},
"/nginx/dead-hosts/{hostID}/disable": {
"post": {
"$ref": "./paths/nginx/dead-hosts/hostID/disable/post.json"
}
},
"/nginx/streams": {
"get": {
"$ref": "./paths/nginx/streams/get.json"
},
"post": {
"$ref": "./paths/nginx/streams/post.json"
}
},
"/nginx/streams/{streamID}": {
"get": {
"$ref": "./paths/nginx/streams/streamID/get.json"
},
"put": {
"$ref": "./paths/nginx/streams/streamID/put.json"
},
"delete": {
"$ref": "./paths/nginx/streams/streamID/delete.json"
}
},
"/nginx/streams/{streamID}/enable": {
"post": {
"$ref": "./paths/nginx/streams/streamID/enable/post.json"
}
},
"/nginx/streams/{streamID}/disable": {
"post": {
"$ref": "./paths/nginx/streams/streamID/disable/post.json"
}
},
"/reports/hosts": {
"get": {
"$ref": "./paths/reports/hosts/get.json"
}
},
"/schema": {
"get": {
"$ref": "./paths/schema/get.json"
}
},
"/settings": {
"get": {
"$ref": "./paths/settings/get.json"
}
},
"/settings/{settingID}": {
"get": {
"$ref": "./paths/settings/settingID/get.json"
},
"put": {
"$ref": "./paths/settings/settingID/put.json"
}
},
"/tokens": {
"get": {
"$ref": "./paths/tokens/get.json"
},
"post": {
"$ref": "./paths/tokens/post.json"
}
},
"/users": {
"get": {
"$ref": "./paths/users/get.json"
},
"post": {
"$ref": "./paths/users/post.json"
}
},
"/users/{userID}": {
"get": {
"$ref": "./paths/users/userID/get.json"
},
"put": {
"$ref": "./paths/users/userID/put.json"
},
"delete": {
"$ref": "./paths/users/userID/delete.json"
}
},
"/users/{userID}/auth": {
"put": {
"$ref": "./paths/users/userID/auth/put.json"
}
},
"/users/{userID}/permissions": {
"put": {
"$ref": "./paths/users/userID/permissions/put.json"
}
},
"/users/{userID}/login": {
"post": {
"$ref": "./paths/users/userID/login/post.json"
}
}
}
}

View File

@@ -1,246 +0,0 @@
# Pricing Configuration Feature
This document describes the new configurable pricing feature that allows MASTER instances to manage pricing plans through the admin interface.
## Overview
The pricing configuration feature allows administrators on MASTER instances to:
- Create, edit, and delete pricing plans
- Configure plan features, prices, and settings
- Set resource quotas for rooms, conversations, storage, and users
- Mark plans as "Most Popular" or "Custom"
- Control plan visibility and ordering
- Update pricing without code changes
## Features
### Pricing Plan Management
- **Plan Name**: Display name for the pricing plan
- **Description**: Optional description shown below the plan name
- **Pricing**: Monthly and annual prices (annual prices are typically 20% lower)
- **Features**: Dynamic list of features with checkmarks
- **Button Configuration**: Customizable button text and URL
- **Plan Types**: Regular plans with prices or custom plans
- **Popular Plans**: Mark one plan as "Most Popular" with special styling
- **Active/Inactive**: Toggle plan visibility
- **Ordering**: Control the display order of plans
### Resource Quotas
- **Room Quota**: Maximum number of rooms allowed (0 = unlimited)
- **Conversation Quota**: Maximum number of conversations allowed (0 = unlimited)
- **Storage Quota**: Maximum storage in gigabytes (0 = unlimited)
- **Manager Quota**: Maximum number of manager users allowed (0 = unlimited)
- **Admin Quota**: Maximum number of admin users allowed (0 = unlimited)
### Admin Interface
- **Pricing Tab**: New tab in admin settings (MASTER instances only)
- **Add/Edit Modals**: User-friendly forms for plan management
- **Real-time Updates**: Changes are reflected immediately
- **Feature Management**: Add/remove features dynamically
- **Quota Configuration**: Set resource limits for each plan
- **Status Toggles**: Quick switches for plan properties
## Setup Instructions
### 1. Database Migration
Run the migrations to create the pricing_plans table and add quota fields:
```bash
# Apply the migrations
alembic upgrade head
```
### 2. Initialize Default Plans (Optional)
Run the initialization script to create default pricing plans:
```bash
# Set MASTER environment variable
export MASTER=true
# Run the initialization script
python init_pricing_plans.py
```
This will create four default plans with quotas:
- **Starter**: 5 rooms, 10 conversations, 10GB storage, 10 managers, 1 admin
- **Professional**: 25 rooms, 50 conversations, 100GB storage, 50 managers, 3 admins
- **Enterprise**: 100 rooms, 200 conversations, 500GB storage, 200 managers, 10 admins
- **Custom**: Unlimited everything
### 3. Access Admin Interface
1. Log in as an admin user on a MASTER instance
2. Go to Settings
3. Click on the "Pricing" tab
4. Configure your pricing plans
## Usage
### Creating a New Plan
1. Click "Add New Plan" in the pricing tab
2. Fill in the plan details:
- **Name**: Plan display name
- **Description**: Optional description
- **Monthly Price**: Price per month
- **Annual Price**: Price per month when billed annually
- **Quotas**: Set resource limits (0 = unlimited)
- **Features**: Add features using the "Add Feature" button
- **Button Text**: Text for the call-to-action button
- **Button URL**: URL the button should link to
- **Options**: Check "Most Popular", "Custom Plan", or "Active" as needed
3. Click "Create Plan"
### Editing a Plan
1. Click the "Edit" button on any plan card
2. Modify the plan details in the modal
3. Click "Update Plan"
### Managing Plan Status
- **Active/Inactive**: Use the toggle switch in the plan header
- **Most Popular**: Check the "Most Popular" checkbox (only one plan can be popular)
- **Custom Plan**: Check "Custom Plan" for plans without fixed pricing
### Deleting a Plan
1. Click the "Delete" button on a plan card
2. Confirm the deletion in the modal
## Technical Details
### Database Schema
The `pricing_plans` table includes:
- `id`: Primary key
- `name`: Plan name (required)
- `description`: Optional description
- `monthly_price`: Monthly price (float)
- `annual_price`: Annual price (float)
- `features`: JSON array of feature strings
- `button_text`: Button display text
- `button_url`: Button link URL
- `is_popular`: Boolean for "Most Popular" styling
- `is_custom`: Boolean for custom plans
- `is_active`: Boolean for plan visibility
- `order_index`: Integer for display ordering
- `room_quota`: Maximum rooms (0 = unlimited)
- `conversation_quota`: Maximum conversations (0 = unlimited)
- `storage_quota_gb`: Maximum storage in GB (0 = unlimited)
- `manager_quota`: Maximum managers (0 = unlimited)
- `admin_quota`: Maximum admins (0 = unlimited)
- `created_by`: Foreign key to user who created the plan
- `created_at`/`updated_at`: Timestamps
### API Endpoints
- `POST /api/admin/pricing-plans` - Create new plan
- `GET /api/admin/pricing-plans/<id>` - Get plan details
- `PUT /api/admin/pricing-plans/<id>` - Update plan
- `DELETE /api/admin/pricing-plans/<id>` - Delete plan
- `PATCH /api/admin/pricing-plans/<id>/status` - Update plan status
### Template Integration
The pricing page automatically uses configured plans:
- Falls back to hardcoded plans if no plans are configured
- Supports dynamic feature lists
- Handles custom plans without pricing
- Shows/hides billing toggle based on plan types
- Displays quota information in plan cards
### Quota Enforcement
The PricingPlan model includes utility methods for quota checking:
- `check_quota(quota_type, current_count)`: Returns True if quota allows the operation
- `get_quota_remaining(quota_type, current_count)`: Returns remaining quota
- `format_quota_display(quota_type)`: Formats quota for display
- `get_storage_quota_bytes()`: Converts GB to bytes for storage calculations
Example usage in your application:
```python
# Check if user can create a new room
plan = PricingPlan.query.get(user_plan_id)
current_rooms = Room.query.filter_by(created_by=user.id).count()
if plan.check_quota('room_quota', current_rooms):
# Allow room creation
pass
else:
# Show upgrade message
pass
```
## Security
- Only admin users can access pricing configuration
- Only MASTER instances can configure pricing
- All API endpoints require authentication and admin privileges
- CSRF protection is enabled for all forms
## Customization
### Styling
The pricing plans use the existing CSS variables:
- `--primary-color`: Main brand color
- `--secondary-color`: Secondary brand color
- `--shadow-color`: Card shadows
### Button URLs
Configure button URLs to point to:
- Contact forms
- Payment processors
- Sales pages
- Custom landing pages
### Features
Features can include:
- Storage limits
- User limits
- Feature availability
- Support levels
- Integration options
### Quota Integration
To integrate quotas into your application:
1. **User Plan Assignment**: Associate users with pricing plans
2. **Quota Checking**: Use the `check_quota()` method before operations
3. **Upgrade Prompts**: Show upgrade messages when quotas are exceeded
4. **Usage Tracking**: Track current usage for quota calculations
## Troubleshooting
### Common Issues
1. **Pricing tab not visible**
- Ensure you're on a MASTER instance (`MASTER=true`)
- Ensure you're logged in as an admin user
2. **Plans not showing on pricing page**
- Check that plans are marked as "Active"
- Verify the database migration was applied
- Check for JavaScript errors in browser console
3. **Features not saving**
- Ensure at least one feature is provided
- Check that feature text is not empty
4. **Quota fields not working**
- Verify the quota migration was applied
- Check that quota values are integers
- Ensure quota fields are included in form submissions
5. **API errors**
- Verify CSRF token is included in requests
- Check that all required fields are provided
- Ensure proper JSON formatting for features
### Debugging
- Check browser console for JavaScript errors
- Review server logs for API errors
- Verify database connectivity
- Test with default plans first
## Future Enhancements
Potential future improvements:
- Plan categories/tiers
- Regional pricing
- Currency support
- Promotional pricing
- Plan comparison features
- Analytics and usage tracking
- Automatic quota enforcement middleware
- Usage dashboard for quota monitoring

View File

@@ -1,59 +0,0 @@
import os
import shutil
from app import create_app
from models import db, RoomFile, Room, RoomMemberPermission
from sqlalchemy import text
app = create_app()
def clear_all_data():
with app.app_context():
# Delete records in the correct order to handle foreign key constraints
# 1. Delete all RoomFile records from the database
RoomFile.query.delete()
print("All RoomFile records deleted.")
# 2. Delete all RoomMemberPermission records
RoomMemberPermission.query.delete()
print("All RoomMemberPermission records deleted.")
# 3. Delete all room_members associations
db.session.execute(text('DELETE FROM room_members'))
print("All room_members associations deleted.")
# 4. Delete all Room records
Room.query.delete()
print("All Room records deleted.")
# Commit the database changes
db.session.commit()
print("Database cleanup completed.")
def clear_filesystem():
# 1. Clear the data/rooms directory
data_root = os.path.join(os.path.dirname(__file__), 'data', 'rooms')
if os.path.exists(data_root):
for item in os.listdir(data_root):
item_path = os.path.join(data_root, item)
if os.path.isfile(item_path):
os.remove(item_path)
elif os.path.isdir(item_path):
shutil.rmtree(item_path)
print("Cleared data/rooms directory")
# 2. Clear the uploads directory except for profile_pics
uploads_dir = os.path.join(os.path.dirname(__file__), 'uploads')
if os.path.exists(uploads_dir):
for item in os.listdir(uploads_dir):
if item != 'profile_pics':
item_path = os.path.join(uploads_dir, item)
if os.path.isfile(item_path):
os.remove(item_path)
elif os.path.isdir(item_path):
shutil.rmtree(item_path)
print("Cleared uploads directory")
if __name__ == '__main__':
clear_all_data()
clear_filesystem()
print("Cleanup completed successfully!")

View File

@@ -1,27 +0,0 @@
from app import create_app, db
from app.models import RoomFile, Room
import os
app = create_app()
with app.app_context():
# Get the Test room
room = Room.query.filter_by(name='Test').first()
if not room:
print("Test room not found")
exit(1)
# Delete from database
files = ['Screenshot_2025-03-19_100338.png', 'Screenshot_2025-03-19_100419.png']
deleted = RoomFile.query.filter_by(room_id=room.id, name__in=files).delete()
db.session.commit()
print(f"Deleted {deleted} records from database")
# Delete from filesystem
room_path = os.path.join('data', 'rooms', str(room.id))
for file in files:
file_path = os.path.join(room_path, file)
if os.path.exists(file_path):
os.remove(file_path)
print(f"Deleted file: {file_path}")
else:
print(f"File not found: {file_path}")

View File

@@ -1,11 +0,0 @@
from app import app, db
from models import Notif
def create_notifs_table():
with app.app_context():
# Create the table
Notif.__table__.create(db.engine)
print("Notifications table created successfully!")
if __name__ == '__main__':
create_notifs_table()

View File

@@ -1,6 +1,8 @@
from flask import Blueprint, jsonify, request
from flask_login import login_required, current_user
from models import db, Room, RoomFile, User, DocuPulseSettings, HelpArticle
from extensions import csrf
from utils.event_logger import log_event
import os
from datetime import datetime
import json
@@ -258,6 +260,7 @@ def get_usage_stats():
@admin.route('/api/admin/help-articles/<int:article_id>', methods=['PUT'])
@login_required
@csrf.exempt
def update_help_article(article_id):
"""Update a help article"""
if not current_user.is_admin:
@@ -310,6 +313,7 @@ def update_help_article(article_id):
@admin.route('/api/admin/help-articles/<int:article_id>', methods=['DELETE'])
@login_required
@csrf.exempt
def delete_help_article(article_id):
"""Delete a help article"""
if not current_user.is_admin:
@@ -342,6 +346,7 @@ def delete_help_article(article_id):
# Help Articles API endpoints
@admin.route('/api/admin/help-articles', methods=['GET'])
@login_required
@csrf.exempt
def get_help_articles():
"""Get all help articles"""
if not current_user.is_admin:
@@ -367,6 +372,7 @@ def get_help_articles():
@admin.route('/api/admin/help-articles', methods=['POST'])
@login_required
@csrf.exempt
def create_help_article():
"""Create a new help article"""
if not current_user.is_admin:
@@ -420,6 +426,7 @@ def create_help_article():
@admin.route('/api/admin/help-articles/<int:article_id>', methods=['GET'])
@login_required
@csrf.exempt
def get_help_article(article_id):
"""Get a specific help article"""
if not current_user.is_admin:

View File

@@ -929,8 +929,8 @@ def deploy_stack():
def check_stack_status():
try:
data = request.get_json()
if not data or 'stack_name' not in data:
return jsonify({'error': 'Missing stack_name field'}), 400
if not data or ('stack_name' not in data and 'stack_id' not in data):
return jsonify({'error': 'Missing stack_name or stack_id field'}), 400
# Get Portainer settings
portainer_settings = KeyValueSettings.get_value('portainer_settings')
@@ -956,35 +956,54 @@ def check_stack_status():
endpoint_id = endpoints[0]['Id']
# Get stack information
stacks_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks"
stacks_response = requests.get(
stacks_url,
headers={
'X-API-Key': portainer_settings['api_key'],
'Accept': 'application/json'
},
params={'filters': json.dumps({'Name': data['stack_name']})},
timeout=30
)
# Get stack information - support both stack_name and stack_id
if 'stack_id' in data:
# Get stack by ID
stack_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{data['stack_id']}"
stack_response = requests.get(
stack_url,
headers={
'X-API-Key': portainer_settings['api_key'],
'Accept': 'application/json'
},
params={'endpointId': endpoint_id},
timeout=30
)
if not stacks_response.ok:
return jsonify({'error': 'Failed to get stack information'}), 500
if not stack_response.ok:
return jsonify({'error': f'Stack with ID {data["stack_id"]} not found'}), 404
stacks = stacks_response.json()
target_stack = None
for stack in stacks:
if stack['Name'] == data['stack_name']:
target_stack = stack
break
target_stack = stack_response.json()
else:
# Get stack by name (existing logic)
stacks_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks"
stacks_response = requests.get(
stacks_url,
headers={
'X-API-Key': portainer_settings['api_key'],
'Accept': 'application/json'
},
params={'filters': json.dumps({'Name': data['stack_name']})},
timeout=30
)
if not target_stack:
return jsonify({'error': f'Stack {data["stack_name"]} not found'}), 404
if not stacks_response.ok:
return jsonify({'error': 'Failed to get stack information'}), 500
stacks = stacks_response.json()
target_stack = None
for stack in stacks:
if stack['Name'] == data['stack_name']:
target_stack = stack
break
if not target_stack:
return jsonify({'error': f'Stack {data["stack_name"]} not found'}), 404
# Get stack services to check their status
services_url = f"{portainer_settings['url'].rstrip('/')}/api/endpoints/{endpoint_id}/docker/services"
current_app.logger.info(f"Checking services for stack {data['stack_name']} at endpoint {endpoint_id}")
current_app.logger.info(f"Checking services for stack {target_stack['Name']} at endpoint {endpoint_id}")
try:
services_response = requests.get(
@@ -993,7 +1012,7 @@ def check_stack_status():
'X-API-Key': portainer_settings['api_key'],
'Accept': 'application/json'
},
params={'filters': json.dumps({'label': f'com.docker.stack.namespace={data["stack_name"]}'})},
params={'filters': json.dumps({'label': f'com.docker.stack.namespace={target_stack["Name"]}'})},
timeout=30
)
@@ -1001,46 +1020,40 @@ def check_stack_status():
if services_response.ok:
services = services_response.json()
current_app.logger.info(f"Found {len(services)} services for stack {data['stack_name']}")
# Check if all services are running
all_running = True
service_statuses = []
for service in services:
replicas_running = service.get('Spec', {}).get('Mode', {}).get('Replicated', {}).get('Replicas', 0)
replicas_actual = service.get('ServiceStatus', {}).get('RunningTasks', 0)
service_status = {
service_statuses.append({
'name': service.get('Spec', {}).get('Name', 'Unknown'),
'replicas_expected': replicas_running,
'replicas_running': replicas_actual,
'status': 'running' if replicas_actual >= replicas_running else 'not_running'
}
service_statuses.append(service_status)
if replicas_actual < replicas_running:
all_running = False
'replicas': service.get('Spec', {}).get('Mode', {}).get('Replicated', {}).get('Replicas', 0),
'running_replicas': service.get('ServiceStatus', {}).get('RunningTasks', 0),
'desired_replicas': service.get('ServiceStatus', {}).get('DesiredTasks', 0)
})
# Determine overall stack status
if all_running and len(services) > 0:
status = 'active'
elif len(services) > 0:
status = 'partial'
if not service_statuses:
status = 'starting' # No services found yet
else:
status = 'inactive'
all_running = all(s['running_replicas'] >= s['desired_replicas'] for s in service_statuses if s['desired_replicas'] > 0)
any_running = any(s['running_replicas'] > 0 for s in service_statuses)
if all_running:
status = 'active'
elif any_running:
status = 'partial'
else:
status = 'inactive'
else:
# Services API failed, but stack exists - assume it's still starting up
current_app.logger.warning(f"Failed to get services for stack {data['stack_name']}: {services_response.status_code} - {services_response.text}")
current_app.logger.warning(f"Failed to get services for stack {target_stack['Name']}: {services_response.status_code} - {services_response.text}")
# Provide more specific error context
if services_response.status_code == 404:
current_app.logger.info(f"Services endpoint not found for stack {data['stack_name']} - stack may still be initializing")
current_app.logger.info(f"Services endpoint not found for stack {target_stack['Name']} - stack may still be initializing")
elif services_response.status_code == 403:
current_app.logger.warning(f"Access denied to services for stack {data['stack_name']} - check Portainer permissions")
current_app.logger.warning(f"Access denied to services for stack {target_stack['Name']} - check Portainer permissions")
elif services_response.status_code >= 500:
current_app.logger.warning(f"Portainer server error when getting services for stack {data['stack_name']}")
current_app.logger.warning(f"Portainer server error when getting services for stack {target_stack['Name']}")
services = []
service_statuses = []
@@ -1048,7 +1061,7 @@ def check_stack_status():
except Exception as e:
# Exception occurred while getting services, but stack exists
current_app.logger.warning(f"Exception getting services for stack {data['stack_name']}: {str(e)}")
current_app.logger.warning(f"Exception getting services for stack {target_stack['Name']}: {str(e)}")
services = []
service_statuses = []
status = 'starting' # Stack exists but services not available yet
@@ -1056,14 +1069,10 @@ def check_stack_status():
return jsonify({
'success': True,
'data': {
'stack_name': data['stack_name'],
'name': target_stack['Name'],
'stack_id': target_stack['Id'],
'status': status,
'services': service_statuses,
'total_services': len(services),
'running_services': len([s for s in service_statuses if s['status'] == 'running']),
'stack_created_at': target_stack.get('CreatedAt', 'unknown'),
'stack_updated_at': target_stack.get('UpdatedAt', 'unknown')
'services': service_statuses
}
})
@@ -1854,4 +1863,216 @@ def copy_smtp_settings():
except Exception as e:
current_app.logger.error(f"Error copying SMTP settings: {str(e)}")
return jsonify({'error': str(e)}), 500
@launch_api.route('/update-stack', methods=['POST'])
@csrf.exempt
def update_stack():
try:
data = request.get_json()
if not data or 'stack_id' not in data:
return jsonify({'error': 'Missing required fields'}), 400
# Get Portainer settings
portainer_settings = KeyValueSettings.get_value('portainer_settings')
if not portainer_settings:
return jsonify({'error': 'Portainer settings not configured'}), 400
# Define timeout early to ensure it's available throughout the function
stack_timeout = current_app.config.get('STACK_DEPLOYMENT_TIMEOUT', 300) # Default to 5 minutes
# Verify Portainer authentication
auth_response = requests.get(
f"{portainer_settings['url'].rstrip('/')}/api/status",
headers={
'X-API-Key': portainer_settings['api_key'],
'Accept': 'application/json'
},
timeout=30 # 30 seconds timeout for status check
)
if not auth_response.ok:
current_app.logger.error(f"Portainer authentication failed: {auth_response.text}")
return jsonify({'error': 'Failed to authenticate with Portainer'}), 401
# Get Portainer endpoint ID (assuming it's the first endpoint)
endpoint_response = requests.get(
f"{portainer_settings['url'].rstrip('/')}/api/endpoints",
headers={
'X-API-Key': portainer_settings['api_key'],
'Accept': 'application/json'
},
timeout=30 # 30 seconds timeout for endpoint check
)
if not endpoint_response.ok:
error_text = endpoint_response.text
try:
error_json = endpoint_response.json()
error_text = error_json.get('message', error_text)
except:
pass
return jsonify({'error': f'Failed to get Portainer endpoints: {error_text}'}), 500
endpoints = endpoint_response.json()
if not endpoints:
return jsonify({'error': 'No Portainer endpoints found'}), 400
endpoint_id = endpoints[0]['Id']
# Log the request data
current_app.logger.info(f"Updating stack with ID: {data['stack_id']}")
current_app.logger.info(f"Using endpoint ID: {endpoint_id}")
current_app.logger.info(f"Using timeout: {stack_timeout} seconds")
# First, verify the stack exists and get its current configuration
stack_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{data['stack_id']}"
stack_response = requests.get(
stack_url,
headers={
'X-API-Key': portainer_settings['api_key'],
'Accept': 'application/json'
},
params={'endpointId': endpoint_id},
timeout=30
)
if not stack_response.ok:
return jsonify({'error': f'Stack with ID {data["stack_id"]} not found'}), 404
stack_info = stack_response.json()
current_app.logger.info(f"Found existing stack: {stack_info['Name']} (ID: {stack_info['Id']})")
# Get the current stack file content from Portainer
stack_file_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{data['stack_id']}/file"
stack_file_response = requests.get(
stack_file_url,
headers={
'X-API-Key': portainer_settings['api_key'],
'Accept': 'application/json'
},
params={'endpointId': endpoint_id},
timeout=30
)
if not stack_file_response.ok:
current_app.logger.error(f"Failed to get stack file: {stack_file_response.text}")
return jsonify({'error': f'Failed to get existing stack file: {stack_file_response.text}'}), 500
stack_file_data = stack_file_response.json()
current_stack_file_content = stack_file_data.get('StackFileContent')
if not current_stack_file_content:
current_app.logger.error("No StackFileContent found in existing stack")
return jsonify({'error': 'No existing stack file content found'}), 500
current_app.logger.info("Retrieved existing stack file content")
# Get existing environment variables from the stack
existing_env_vars = stack_file_data.get('Env', [])
current_app.logger.info(f"Retrieved {len(existing_env_vars)} existing environment variables")
# Create a dictionary of existing environment variables for easy lookup
existing_env_dict = {env['name']: env['value'] for env in existing_env_vars}
current_app.logger.info(f"Existing environment variables: {list(existing_env_dict.keys())}")
# Get new environment variables from the request
new_env_vars = data.get('Env', [])
current_app.logger.info(f"New environment variables to update: {[env['name'] for env in new_env_vars]}")
# Merge existing and new environment variables
# Start with existing variables
merged_env_vars = existing_env_vars.copy()
# Update with new variables (this will overwrite existing ones with the same name)
for new_env in new_env_vars:
# Find if this environment variable already exists
existing_index = None
for i, existing_env in enumerate(merged_env_vars):
if existing_env['name'] == new_env['name']:
existing_index = i
break
if existing_index is not None:
# Update existing variable
merged_env_vars[existing_index]['value'] = new_env['value']
current_app.logger.info(f"Updated environment variable: {new_env['name']} = {new_env['value']}")
else:
# Add new variable
merged_env_vars.append(new_env)
current_app.logger.info(f"Added new environment variable: {new_env['name']} = {new_env['value']}")
current_app.logger.info(f"Final merged environment variables: {[env['name'] for env in merged_env_vars]}")
# Update the stack using Portainer's update API
update_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{data['stack_id']}"
current_app.logger.info(f"Making update request to: {update_url}")
# Prepare the request body for stack update
request_body = {
'StackFileContent': current_stack_file_content, # Use existing stack file content
'Env': merged_env_vars # Use merged environment variables
}
# If new StackFileContent is provided, use it instead
if 'StackFileContent' in data:
request_body['StackFileContent'] = data['StackFileContent']
current_app.logger.info("Using provided StackFileContent for update")
else:
current_app.logger.info("Using existing StackFileContent for update")
# Add endpointId as a query parameter
params = {'endpointId': endpoint_id}
# Use a configurable timeout for stack update initiation
update_response = requests.put(
update_url,
headers={
'X-API-Key': portainer_settings['api_key'],
'Content-Type': 'application/json',
'Accept': 'application/json'
},
params=params,
json=request_body,
timeout=stack_timeout # Use configurable timeout
)
# Log the response details
current_app.logger.info(f"Update response status: {update_response.status_code}")
current_app.logger.info(f"Update response headers: {dict(update_response.headers)}")
response_text = update_response.text
current_app.logger.info(f"Update response body: {response_text}")
if not update_response.ok:
error_message = response_text
try:
error_json = update_response.json()
error_message = error_json.get('message', error_message)
except:
pass
return jsonify({'error': f'Failed to update stack: {error_message}'}), 500
# Stack update initiated successfully
current_app.logger.info(f"Stack update initiated: {stack_info['Name']} (ID: {stack_info['Id']})")
return jsonify({
'success': True,
'data': {
'name': stack_info['Name'],
'id': stack_info['Id'],
'status': 'updating'
}
})
except requests.exceptions.Timeout:
current_app.logger.error(f"Request timed out after {stack_timeout} seconds while initiating stack update")
current_app.logger.error(f"Stack ID: {data.get('stack_id', 'unknown') if 'data' in locals() else 'unknown'}")
current_app.logger.error(f"Portainer URL: {portainer_settings.get('url', 'unknown') if 'portainer_settings' in locals() else 'unknown'}")
return jsonify({
'error': f'Request timed out after {stack_timeout} seconds while initiating stack update. The operation may still be in progress.',
'timeout_seconds': stack_timeout,
'stack_id': data.get('stack_id', 'unknown') if 'data' in locals() else 'unknown'
}), 504
except Exception as e:
current_app.logger.error(f"Error updating stack: {str(e)}")
return jsonify({'error': str(e)}), 500

View File

@@ -19,6 +19,7 @@ import smtplib
import requests
from functools import wraps
import socket
from urllib.parse import urlparse
# Set up logging to show in console
logging.basicConfig(
@@ -491,13 +492,197 @@ def init_routes(main_bp):
return jsonify({'error': 'Unauthorized'}), 403
instance = Instance.query.get_or_404(instance_id)
# Get Portainer settings
portainer_settings = KeyValueSettings.get_value('portainer_settings')
if not portainer_settings:
current_app.logger.warning(f"Portainer settings not configured, proceeding with database deletion only for instance {instance.name}")
# Continue with database deletion even if Portainer is not configured
try:
db.session.delete(instance)
db.session.commit()
current_app.logger.info(f"Successfully deleted instance from database: {instance.name}")
return jsonify({'message': 'Instance deleted from database (Portainer not configured)'})
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error deleting instance {instance.name} from database: {str(e)}")
return jsonify({'error': f'Failed to delete instance: {str(e)}'}), 500
try:
# First, delete the Portainer stack and its volumes if stack information exists
if instance.portainer_stack_id and instance.portainer_stack_name:
current_app.logger.info(f"Deleting Portainer stack: {instance.portainer_stack_name} (ID: {instance.portainer_stack_id})")
# Get Portainer endpoint ID (assuming it's the first endpoint)
try:
endpoint_response = requests.get(
f"{portainer_settings['url'].rstrip('/')}/api/endpoints",
headers={
'X-API-Key': portainer_settings['api_key'],
'Accept': 'application/json'
},
timeout=30
)
if not endpoint_response.ok:
current_app.logger.error(f"Failed to get Portainer endpoints: {endpoint_response.text}")
return jsonify({'error': 'Failed to get Portainer endpoints'}), 500
endpoints = endpoint_response.json()
if not endpoints:
current_app.logger.error("No Portainer endpoints found")
return jsonify({'error': 'No Portainer endpoints found'}), 400
endpoint_id = endpoints[0]['Id']
# Delete the stack (this will also remove associated volumes)
delete_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{instance.portainer_stack_id}"
delete_response = requests.delete(
delete_url,
headers={
'X-API-Key': portainer_settings['api_key'],
'Accept': 'application/json'
},
params={'endpointId': endpoint_id},
timeout=60 # Give more time for stack deletion
)
if delete_response.ok:
current_app.logger.info(f"Successfully deleted Portainer stack: {instance.portainer_stack_name}")
else:
current_app.logger.warning(f"Failed to delete Portainer stack: {delete_response.status_code} - {delete_response.text}")
# Continue with database deletion even if Portainer deletion fails
# Also try to delete any orphaned volumes associated with this stack
try:
volumes_url = f"{portainer_settings['url'].rstrip('/')}/api/endpoints/{endpoint_id}/docker/volumes"
volumes_response = requests.get(
volumes_url,
headers={
'X-API-Key': portainer_settings['api_key'],
'Accept': 'application/json'
},
timeout=30
)
if volumes_response.ok:
volumes = volumes_response.json().get('Volumes', [])
stack_volumes = [vol for vol in volumes if vol.get('Labels', {}).get('com.docker.stack.namespace') == instance.portainer_stack_name]
for volume in stack_volumes:
volume_name = volume.get('Name')
if volume_name:
delete_volume_url = f"{volumes_url}/{volume_name}"
volume_delete_response = requests.delete(
delete_volume_url,
headers={
'X-API-Key': portainer_settings['api_key'],
'Accept': 'application/json'
},
timeout=30
)
if volume_delete_response.ok:
current_app.logger.info(f"Successfully deleted volume: {volume_name}")
else:
current_app.logger.warning(f"Failed to delete volume {volume_name}: {volume_delete_response.status_code}")
else:
current_app.logger.warning(f"Failed to get volumes list: {volumes_response.status_code}")
except Exception as volume_error:
current_app.logger.warning(f"Error cleaning up volumes: {str(volume_error)}")
except requests.exceptions.RequestException as req_error:
current_app.logger.error(f"Network error during Portainer operations: {str(req_error)}")
# Continue with database deletion even if Portainer operations fail
else:
current_app.logger.info(f"No Portainer stack information found for instance {instance.name}, proceeding with database deletion only")
# Clean up NGINX proxy host if NGINX settings are configured
nginx_settings = KeyValueSettings.get_value('nginx_settings')
if nginx_settings and instance.main_url:
current_app.logger.info(f"Cleaning up NGINX proxy host for instance {instance.name}")
try:
# Extract domain from main_url
parsed_url = urlparse(instance.main_url)
domain = parsed_url.netloc
if domain:
# Get NGINX JWT token
token_response = requests.post(
f"{nginx_settings['url'].rstrip('/')}/api/tokens",
json={
'identity': nginx_settings['username'],
'secret': nginx_settings['password']
},
headers={'Content-Type': 'application/json'},
timeout=30
)
if token_response.ok:
token_data = token_response.json()
token = token_data.get('token')
if token:
# Get all proxy hosts to find the one matching this domain
proxy_hosts_response = requests.get(
f"{nginx_settings['url'].rstrip('/')}/api/nginx/proxy-hosts",
headers={
'Authorization': f'Bearer {token}',
'Accept': 'application/json'
},
timeout=30
)
if proxy_hosts_response.ok:
proxy_hosts = proxy_hosts_response.json()
# Find proxy host with matching domain
matching_proxy = None
for proxy_host in proxy_hosts:
if proxy_host.get('domain_names') and domain in proxy_host['domain_names']:
matching_proxy = proxy_host
break
if matching_proxy:
# Delete the proxy host
delete_response = requests.delete(
f"{nginx_settings['url'].rstrip('/')}/api/nginx/proxy-hosts/{matching_proxy['id']}",
headers={
'Authorization': f'Bearer {token}',
'Accept': 'application/json'
},
timeout=30
)
if delete_response.ok:
current_app.logger.info(f"Successfully deleted NGINX proxy host for domain: {domain}")
else:
current_app.logger.warning(f"Failed to delete NGINX proxy host: {delete_response.status_code} - {delete_response.text}")
else:
current_app.logger.info(f"No NGINX proxy host found for domain: {domain}")
else:
current_app.logger.warning(f"Failed to get NGINX proxy hosts: {proxy_hosts_response.status_code}")
else:
current_app.logger.warning("No NGINX token received")
else:
current_app.logger.warning(f"Failed to authenticate with NGINX: {token_response.status_code}")
except Exception as nginx_error:
current_app.logger.warning(f"Error cleaning up NGINX proxy host: {str(nginx_error)}")
# Continue with database deletion even if NGINX cleanup fails
else:
current_app.logger.info(f"No NGINX settings configured or no main_url for instance {instance.name}, skipping NGINX cleanup")
# Now delete the instance from the database
db.session.delete(instance)
db.session.commit()
return jsonify({'message': 'Instance deleted successfully'})
current_app.logger.info(f"Successfully deleted instance: {instance.name}")
return jsonify({'message': 'Instance and all associated resources (Portainer stack, volumes, NGINX proxy host) deleted successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 400
current_app.logger.error(f"Error deleting instance {instance.name}: {str(e)}")
return jsonify({'error': f'Failed to delete instance: {str(e)}'}), 500
@main_bp.route('/instances/<int:instance_id>/status')
@login_required
@@ -589,6 +774,32 @@ def init_routes(main_bp):
return render_template('main/instance_detail.html', instance=instance)
@main_bp.route('/api/instances/<int:instance_id>')
@login_required
@require_password_change
def get_instance_data(instance_id):
if not os.environ.get('MASTER', 'false').lower() == 'true':
return jsonify({'error': 'Unauthorized'}), 403
instance = Instance.query.get_or_404(instance_id)
return jsonify({
'success': True,
'instance': {
'id': instance.id,
'name': instance.name,
'company': instance.company,
'main_url': instance.main_url,
'status': instance.status,
'payment_plan': instance.payment_plan,
'portainer_stack_id': instance.portainer_stack_id,
'portainer_stack_name': instance.portainer_stack_name,
'deployed_version': instance.deployed_version,
'deployed_branch': instance.deployed_branch,
'connection_token': instance.connection_token
}
})
@main_bp.route('/instances/<int:instance_id>/auth-status')
@login_required
@require_password_change
@@ -1954,6 +2165,12 @@ def init_routes(main_bp):
flash('This page is only available in master instances.', 'error')
return redirect(url_for('main.dashboard'))
# Get update parameters if this is an update operation
is_update = request.args.get('update', 'false').lower() == 'true'
instance_id = request.args.get('instance_id')
repo_id = request.args.get('repo')
branch = request.args.get('branch')
# Get NGINX settings
nginx_settings = KeyValueSettings.get_value('nginx_settings')
# Get Portainer settings
@@ -1964,7 +2181,11 @@ def init_routes(main_bp):
return render_template('main/launch_progress.html',
nginx_settings=nginx_settings,
portainer_settings=portainer_settings,
cloudflare_settings=cloudflare_settings)
cloudflare_settings=cloudflare_settings,
is_update=is_update,
instance_id=instance_id,
repo_id=repo_id,
branch=branch)
@main_bp.route('/api/check-dns', methods=['POST'])
@login_required

View File

@@ -57,7 +57,12 @@ def init_public_routes(public_bp):
articles = HelpArticle.get_articles_by_category(category)
category_name = categories[category]
else:
# Show all articles when no specific category is requested
articles = []
for category_articles in all_articles.values():
articles.extend(category_articles)
# Sort by order_index and then by created_at
articles.sort(key=lambda x: (x.order_index, x.created_at))
category_name = None
return render_template('public/help_articles.html',

252
static/css/instances.css Normal file
View File

@@ -0,0 +1,252 @@
/* Instances Page Styles */
/* Table Styles */
.table td {
vertical-align: middle;
}
/* Version column styling */
.version-badge {
font-family: monospace;
font-size: 0.85em;
}
.branch-badge {
font-size: 0.85em;
}
/* Make table responsive */
.table-responsive {
overflow-x: auto;
}
/* Tooltip styling for version info */
.tooltip-inner {
max-width: 300px;
text-align: left;
}
/* Version comparison styling */
.version-outdated {
background-color: #fff3cd !important;
border-color: #ffeaa7 !important;
}
.version-current {
background-color: #d1ecf1 !important;
border-color: #bee5eb !important;
}
.badge.bg-orange {
background-color: #fd7e14 !important;
color: white !important;
}
.badge.bg-orange:hover {
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;
}
/* Step Navigation Styles */
.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 Styles */
.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 Styles */
.modal-footer {
display: flex;
justify-content: flex-end !important;
gap: 0.5rem;
}
.modal-footer button {
margin-left: 0.5rem;
}
/* Infrastructure Tools Styles */
.infrastructure-tools .btn {
transition: all 0.3s ease;
border-radius: 12px;
min-height: 100px;
text-decoration: none;
}
.infrastructure-tools .btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
text-decoration: none;
}
.infrastructure-tools .btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.infrastructure-tools .btn:disabled:hover {
transform: none;
box-shadow: none;
}
.infrastructure-tools .btn-outline-primary:hover {
background-color: var(--primary-color);
border-color: var(--primary-color);
color: white;
}
.infrastructure-tools .btn-outline-success:hover {
background-color: #198754;
border-color: #198754;
color: white;
}
.infrastructure-tools .btn-outline-info:hover {
background-color: #0dcaf0;
border-color: #0dcaf0;
color: white;
}
.infrastructure-tools .btn-outline-warning:hover {
background-color: #ffc107;
border-color: #ffc107;
color: #000;
}
.infrastructure-tools .btn i {
transition: transform 0.3s ease;
}
.infrastructure-tools .btn:hover i {
transform: scale(1.1);
}

1138
static/js/instance_detail.js Normal file

File diff suppressed because it is too large Load Diff

1943
static/js/instances.js Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,10 @@
{% block title %}Support Articles - DocuPulse{% endblock %}
{% block head %}
<meta name="csrf-token" content="{{ csrf_token }}">
{% endblock %}
{% block extra_css %}
<link href="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.css" rel="stylesheet">
<style>
@@ -227,6 +231,7 @@
<div class="modal-body">
<p>Are you sure you want to delete this article? This action cannot be undone.</p>
<p class="text-muted" id="deleteArticleTitle"></p>
<input type="hidden" id="deleteArticleId" value="">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
@@ -295,7 +300,15 @@ document.addEventListener('DOMContentLoaded', function() {
function loadArticles() {
fetch('/api/admin/help-articles')
.then(response => response.json())
.then(response => {
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
return response.text().then(text => {
throw new Error(`Server returned non-JSON response: ${text.substring(0, 200)}...`);
});
}
return response.json();
})
.then(data => {
const articlesList = document.getElementById('articlesList');
articlesList.innerHTML = '';
@@ -377,7 +390,17 @@ function createArticle() {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(response => {
// Check if response is JSON
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
// If not JSON, get the text and throw an error
return response.text().then(text => {
throw new Error(`Server returned non-JSON response: ${text.substring(0, 200)}...`);
});
}
return response.json();
})
.then(data => {
if (data.success) {
const modal = bootstrap.Modal.getInstance(document.getElementById('createArticleModal'));
@@ -398,7 +421,15 @@ function createArticle() {
function editArticle(articleId) {
fetch(`/api/admin/help-articles/${articleId}`)
.then(response => response.json())
.then(response => {
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
return response.text().then(text => {
throw new Error(`Server returned non-JSON response: ${text.substring(0, 200)}...`);
});
}
return response.json();
})
.then(data => {
document.getElementById('editArticleId').value = data.article.id;
document.getElementById('editArticleTitle').value = data.article.title;
@@ -426,7 +457,15 @@ function updateArticle() {
method: 'PUT',
body: formData
})
.then(response => response.json())
.then(response => {
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
return response.text().then(text => {
throw new Error(`Server returned non-JSON response: ${text.substring(0, 200)}...`);
});
}
return response.json();
})
.then(data => {
if (data.success) {
const modal = bootstrap.Modal.getInstance(document.getElementById('editArticleModal'));
@@ -457,7 +496,15 @@ function deleteArticle() {
fetch(`/api/admin/help-articles/${articleId}`, {
method: 'DELETE'
})
.then(response => response.json())
.then(response => {
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
return response.text().then(text => {
throw new Error(`Server returned non-JSON response: ${text.substring(0, 200)}...`);
});
}
return response.json();
})
.then(data => {
if (data.success) {
const modal = bootstrap.Modal.getInstance(document.getElementById('deleteArticleModal'));

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<link rel="stylesheet" href="{{ url_for('static', filename='css/home.css') }}?v={{ 'css/home.css'|asset_version }}">
<style>
.admin-link {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -9,9 +9,9 @@
{% block content %}
{{ header(
title="Launching Instance",
description="Setting up your new DocuPulse instance",
icon="fa-rocket"
title=is_update and "Updating Instance" or "Launching Instance",
description=is_update and "Updating your DocuPulse instance with the latest version" or "Setting up your new DocuPulse instance",
icon="fa-arrow-up" if is_update else "fa-rocket"
) }}
<div class="container-fluid">
@@ -78,6 +78,12 @@
// Pass CSRF token to JavaScript
window.csrfToken = '{{ csrf_token }}';
// Pass update parameters if this is an update operation
window.isUpdate = {{ 'true' if is_update else 'false' }};
window.updateInstanceId = '{{ instance_id or "" }}';
window.updateRepoId = '{{ repo_id or "" }}';
window.updateBranch = '{{ branch or "" }}';
</script>
<script src="{{ url_for('static', filename='js/launch_progress.js') }}"></script>
{% endblock %}

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<style>
.about-section {
padding: 80px 0;

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<style>
.careers-section {
padding: 80px 0;

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<style>
.legal-section {
padding: 80px 0;

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<style>
.contact-section {
padding: 80px 0;

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<style>
/* Enhanced Features Page Styles */
.hero-section {

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<style>
.legal-section {
padding: 80px 0;

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<style>
.help-section {
padding: 80px 0;
@@ -415,23 +416,5 @@
{% include 'components/footer_nav.html' %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Search functionality
document.querySelector('.search-box').addEventListener('input', function(e) {
const searchTerm = e.target.value.toLowerCase();
const faqItems = document.querySelectorAll('.faq-item');
faqItems.forEach(item => {
const question = item.querySelector('.faq-question').textContent.toLowerCase();
const answer = item.querySelector('.faq-answer').textContent.toLowerCase();
if (question.includes(searchTerm) || answer.includes(searchTerm)) {
item.style.display = 'block';
} else {
item.style.display = 'none';
}
});
});
</script>
</body>
</html>

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<style>
.category-nav {
background: var(--white);

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<style>
.press-section {
padding: 80px 0;

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<style>
.pricing-section {
padding: 80px 0;

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<style>
.legal-section {
padding: 80px 0;

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<style>
.security-section {
padding: 80px 0;

View File

@@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
<style>
.legal-section {
padding: 80px 0;