Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81675af837 | |||
| 0a2cddf122 | |||
| 56d94a06ce | |||
| de3880e880 | |||
| 0466b11c71 | |||
| e519dc3a8b | |||
| ac9f002365 | |||
| 8de74827f2 | |||
| 81552bc5ec | |||
| 490bc05a9e | |||
| cc699506d3 |
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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!")
|
|
||||||
@@ -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}")
|
|
||||||
@@ -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()
|
|
||||||
@@ -25,6 +25,13 @@ services:
|
|||||||
- GIT_COMMIT=${GIT_COMMIT:-unknown}
|
- GIT_COMMIT=${GIT_COMMIT:-unknown}
|
||||||
- GIT_BRANCH=${GIT_BRANCH:-unknown}
|
- GIT_BRANCH=${GIT_BRANCH:-unknown}
|
||||||
- DEPLOYED_AT=${DEPLOYED_AT:-unknown}
|
- DEPLOYED_AT=${DEPLOYED_AT:-unknown}
|
||||||
|
- PRICING_TIER_ID=${PRICING_TIER_ID:-0}
|
||||||
|
- PRICING_TIER_NAME=${PRICING_TIER_NAME:-Unknown}
|
||||||
|
- ROOM_QUOTA=${ROOM_QUOTA:-0}
|
||||||
|
- CONVERSATION_QUOTA=${CONVERSATION_QUOTA:-0}
|
||||||
|
- STORAGE_QUOTA_GB=${STORAGE_QUOTA_GB:-0}
|
||||||
|
- MANAGER_QUOTA=${MANAGER_QUOTA:-0}
|
||||||
|
- ADMIN_QUOTA=${ADMIN_QUOTA:-0}
|
||||||
volumes:
|
volumes:
|
||||||
- docupulse_uploads:/app/uploads
|
- docupulse_uploads:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,8 @@
|
|||||||
from flask import Blueprint, jsonify, request
|
from flask import Blueprint, jsonify, request
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from models import db, Room, RoomFile, User, DocuPulseSettings, HelpArticle
|
from models import db, Room, RoomFile, User, DocuPulseSettings, HelpArticle
|
||||||
|
from extensions import csrf
|
||||||
|
from utils.event_logger import log_event
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import json
|
import json
|
||||||
@@ -258,6 +260,7 @@ def get_usage_stats():
|
|||||||
|
|
||||||
@admin.route('/api/admin/help-articles/<int:article_id>', methods=['PUT'])
|
@admin.route('/api/admin/help-articles/<int:article_id>', methods=['PUT'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@csrf.exempt
|
||||||
def update_help_article(article_id):
|
def update_help_article(article_id):
|
||||||
"""Update a help article"""
|
"""Update a help article"""
|
||||||
if not current_user.is_admin:
|
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'])
|
@admin.route('/api/admin/help-articles/<int:article_id>', methods=['DELETE'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@csrf.exempt
|
||||||
def delete_help_article(article_id):
|
def delete_help_article(article_id):
|
||||||
"""Delete a help article"""
|
"""Delete a help article"""
|
||||||
if not current_user.is_admin:
|
if not current_user.is_admin:
|
||||||
@@ -342,6 +346,7 @@ def delete_help_article(article_id):
|
|||||||
# Help Articles API endpoints
|
# Help Articles API endpoints
|
||||||
@admin.route('/api/admin/help-articles', methods=['GET'])
|
@admin.route('/api/admin/help-articles', methods=['GET'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@csrf.exempt
|
||||||
def get_help_articles():
|
def get_help_articles():
|
||||||
"""Get all help articles"""
|
"""Get all help articles"""
|
||||||
if not current_user.is_admin:
|
if not current_user.is_admin:
|
||||||
@@ -367,6 +372,7 @@ def get_help_articles():
|
|||||||
|
|
||||||
@admin.route('/api/admin/help-articles', methods=['POST'])
|
@admin.route('/api/admin/help-articles', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@csrf.exempt
|
||||||
def create_help_article():
|
def create_help_article():
|
||||||
"""Create a new help article"""
|
"""Create a new help article"""
|
||||||
if not current_user.is_admin:
|
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'])
|
@admin.route('/api/admin/help-articles/<int:article_id>', methods=['GET'])
|
||||||
@login_required
|
@login_required
|
||||||
|
@csrf.exempt
|
||||||
def get_help_article(article_id):
|
def get_help_article(article_id):
|
||||||
"""Get a specific help article"""
|
"""Get a specific help article"""
|
||||||
if not current_user.is_admin:
|
if not current_user.is_admin:
|
||||||
|
|||||||
227
routes/main.py
227
routes/main.py
@@ -19,6 +19,7 @@ import smtplib
|
|||||||
import requests
|
import requests
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
import socket
|
import socket
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
# Set up logging to show in console
|
# Set up logging to show in console
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -491,13 +492,197 @@ def init_routes(main_bp):
|
|||||||
return jsonify({'error': 'Unauthorized'}), 403
|
return jsonify({'error': 'Unauthorized'}), 403
|
||||||
|
|
||||||
instance = Instance.query.get_or_404(instance_id)
|
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:
|
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.delete(instance)
|
||||||
db.session.commit()
|
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:
|
except Exception as e:
|
||||||
db.session.rollback()
|
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')
|
@main_bp.route('/instances/<int:instance_id>/status')
|
||||||
@login_required
|
@login_required
|
||||||
@@ -589,6 +774,32 @@ def init_routes(main_bp):
|
|||||||
|
|
||||||
return render_template('main/instance_detail.html', instance=instance)
|
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')
|
@main_bp.route('/instances/<int:instance_id>/auth-status')
|
||||||
@login_required
|
@login_required
|
||||||
@require_password_change
|
@require_password_change
|
||||||
@@ -1954,6 +2165,12 @@ def init_routes(main_bp):
|
|||||||
flash('This page is only available in master instances.', 'error')
|
flash('This page is only available in master instances.', 'error')
|
||||||
return redirect(url_for('main.dashboard'))
|
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
|
# Get NGINX settings
|
||||||
nginx_settings = KeyValueSettings.get_value('nginx_settings')
|
nginx_settings = KeyValueSettings.get_value('nginx_settings')
|
||||||
# Get Portainer settings
|
# Get Portainer settings
|
||||||
@@ -1964,7 +2181,11 @@ def init_routes(main_bp):
|
|||||||
return render_template('main/launch_progress.html',
|
return render_template('main/launch_progress.html',
|
||||||
nginx_settings=nginx_settings,
|
nginx_settings=nginx_settings,
|
||||||
portainer_settings=portainer_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'])
|
@main_bp.route('/api/check-dns', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
@@ -57,7 +57,12 @@ def init_public_routes(public_bp):
|
|||||||
articles = HelpArticle.get_articles_by_category(category)
|
articles = HelpArticle.get_articles_by_category(category)
|
||||||
category_name = categories[category]
|
category_name = categories[category]
|
||||||
else:
|
else:
|
||||||
|
# Show all articles when no specific category is requested
|
||||||
articles = []
|
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
|
category_name = None
|
||||||
|
|
||||||
return render_template('public/help_articles.html',
|
return render_template('public/help_articles.html',
|
||||||
|
|||||||
252
static/css/instances.css
Normal file
252
static/css/instances.css
Normal 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
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
1943
static/js/instances.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,236 +1,280 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Get the launch data from sessionStorage
|
// Check if this is an update operation
|
||||||
const launchData = JSON.parse(sessionStorage.getItem('instanceLaunchData'));
|
if (window.isUpdate && window.updateInstanceId && window.updateRepoId && window.updateBranch) {
|
||||||
if (!launchData) {
|
// This is an update operation
|
||||||
showError('No launch data found. Please start over.');
|
const updateData = {
|
||||||
return;
|
instanceId: window.updateInstanceId,
|
||||||
}
|
repository: window.updateRepoId,
|
||||||
|
branch: window.updateBranch,
|
||||||
|
isUpdate: true
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize the steps
|
||||||
|
initializeSteps();
|
||||||
|
|
||||||
|
// Start the update process
|
||||||
|
startUpdate(updateData);
|
||||||
|
} else {
|
||||||
|
// This is a new launch operation
|
||||||
|
const launchData = JSON.parse(sessionStorage.getItem('instanceLaunchData'));
|
||||||
|
if (!launchData) {
|
||||||
|
showError('No launch data found. Please start over.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize the steps
|
// Initialize the steps
|
||||||
initializeSteps();
|
initializeSteps();
|
||||||
|
|
||||||
// Start the launch process
|
// Start the launch process
|
||||||
startLaunch(launchData);
|
startLaunch(launchData);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function initializeSteps() {
|
function initializeSteps() {
|
||||||
const stepsContainer = document.getElementById('stepsContainer');
|
const stepsContainer = document.getElementById('stepsContainer');
|
||||||
|
const isUpdate = window.isUpdate;
|
||||||
|
|
||||||
// Add Cloudflare connection check step
|
if (isUpdate) {
|
||||||
const cloudflareStep = document.createElement('div');
|
// For updates, show fewer steps
|
||||||
cloudflareStep.className = 'step-item';
|
const steps = [
|
||||||
cloudflareStep.innerHTML = `
|
{ icon: 'fab fa-docker', title: 'Checking Portainer Connection', description: 'Verifying connection to Portainer...' },
|
||||||
<div class="step-icon"><i class="fas fa-cloud"></i></div>
|
{ icon: 'fas fa-file-code', title: 'Downloading Docker Compose', description: 'Fetching docker-compose.yml from repository...' },
|
||||||
<div class="step-content">
|
{ icon: 'fab fa-docker', title: 'Deploying Updated Stack', description: 'Deploying the updated application stack...' },
|
||||||
<h5>Checking Cloudflare Connection</h5>
|
{ icon: 'fas fa-save', title: 'Updating Instance Data', description: 'Updating instance information...' },
|
||||||
<p class="step-status">Verifying Cloudflare API connection...</p>
|
{ icon: 'fas fa-heartbeat', title: 'Health Check', description: 'Verifying updated instance health...' },
|
||||||
</div>
|
{ icon: 'fas fa-check-circle', title: 'Update Complete', description: 'Instance has been successfully updated!' }
|
||||||
`;
|
];
|
||||||
stepsContainer.appendChild(cloudflareStep);
|
|
||||||
|
steps.forEach((step, index) => {
|
||||||
|
const stepElement = document.createElement('div');
|
||||||
|
stepElement.className = 'step-item';
|
||||||
|
stepElement.innerHTML = `
|
||||||
|
<div class="step-icon"><i class="${step.icon}"></i></div>
|
||||||
|
<div class="step-content">
|
||||||
|
<h5>${step.title}</h5>
|
||||||
|
<p class="step-status">${step.description}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
stepsContainer.appendChild(stepElement);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// For new launches, show all steps
|
||||||
|
// Add Cloudflare connection check step
|
||||||
|
const cloudflareStep = document.createElement('div');
|
||||||
|
cloudflareStep.className = 'step-item';
|
||||||
|
cloudflareStep.innerHTML = `
|
||||||
|
<div class="step-icon"><i class="fas fa-cloud"></i></div>
|
||||||
|
<div class="step-content">
|
||||||
|
<h5>Checking Cloudflare Connection</h5>
|
||||||
|
<p class="step-status">Verifying Cloudflare API connection...</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
stepsContainer.appendChild(cloudflareStep);
|
||||||
|
|
||||||
// Add DNS record creation step
|
// Add DNS record creation step
|
||||||
const dnsCreateStep = document.createElement('div');
|
const dnsCreateStep = document.createElement('div');
|
||||||
dnsCreateStep.className = 'step-item';
|
dnsCreateStep.className = 'step-item';
|
||||||
dnsCreateStep.innerHTML = `
|
dnsCreateStep.innerHTML = `
|
||||||
<div class="step-icon"><i class="fas fa-plus-circle"></i></div>
|
<div class="step-icon"><i class="fas fa-plus-circle"></i></div>
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
<h5>Creating DNS Records</h5>
|
<h5>Creating DNS Records</h5>
|
||||||
<p class="step-status">Setting up domain DNS records in Cloudflare...</p>
|
<p class="step-status">Setting up domain DNS records in Cloudflare...</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
stepsContainer.appendChild(dnsCreateStep);
|
stepsContainer.appendChild(dnsCreateStep);
|
||||||
|
|
||||||
// Add DNS check step
|
// Add DNS check step
|
||||||
const dnsStep = document.createElement('div');
|
const dnsStep = document.createElement('div');
|
||||||
dnsStep.className = 'step-item';
|
dnsStep.className = 'step-item';
|
||||||
dnsStep.innerHTML = `
|
dnsStep.innerHTML = `
|
||||||
<div class="step-icon"><i class="fas fa-globe"></i></div>
|
<div class="step-icon"><i class="fas fa-globe"></i></div>
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
<h5>Checking DNS Records</h5>
|
<h5>Checking DNS Records</h5>
|
||||||
<p class="step-status">Verifying domain configurations...</p>
|
<p class="step-status">Verifying domain configurations...</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
stepsContainer.appendChild(dnsStep);
|
stepsContainer.appendChild(dnsStep);
|
||||||
|
|
||||||
// Add NGINX connection check step
|
// Add NGINX connection check step
|
||||||
const nginxStep = document.createElement('div');
|
const nginxStep = document.createElement('div');
|
||||||
nginxStep.className = 'step-item';
|
nginxStep.className = 'step-item';
|
||||||
nginxStep.innerHTML = `
|
nginxStep.innerHTML = `
|
||||||
<div class="step-icon"><i class="fas fa-network-wired"></i></div>
|
<div class="step-icon"><i class="fas fa-network-wired"></i></div>
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
<h5>Checking NGINX Connection</h5>
|
<h5>Checking NGINX Connection</h5>
|
||||||
<p class="step-status">Verifying connection to NGINX Proxy Manager...</p>
|
<p class="step-status">Verifying connection to NGINX Proxy Manager...</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
stepsContainer.appendChild(nginxStep);
|
stepsContainer.appendChild(nginxStep);
|
||||||
|
|
||||||
// Add SSL Certificate generation step
|
// Add SSL Certificate generation step
|
||||||
const sslStep = document.createElement('div');
|
const sslStep = document.createElement('div');
|
||||||
sslStep.className = 'step-item';
|
sslStep.className = 'step-item';
|
||||||
sslStep.innerHTML = `
|
sslStep.innerHTML = `
|
||||||
<div class="step-icon"><i class="fas fa-lock"></i></div>
|
<div class="step-icon"><i class="fas fa-lock"></i></div>
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
<h5>Generating SSL Certificate</h5>
|
<h5>Generating SSL Certificate</h5>
|
||||||
<p class="step-status">Setting up secure HTTPS connection...</p>
|
<p class="step-status">Setting up secure HTTPS connection...</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
stepsContainer.appendChild(sslStep);
|
stepsContainer.appendChild(sslStep);
|
||||||
|
|
||||||
// Add Proxy Host creation step
|
// Add Proxy Host creation step
|
||||||
const proxyStep = document.createElement('div');
|
const proxyStep = document.createElement('div');
|
||||||
proxyStep.className = 'step-item';
|
proxyStep.className = 'step-item';
|
||||||
proxyStep.innerHTML = `
|
proxyStep.innerHTML = `
|
||||||
<div class="step-icon"><i class="fas fa-server"></i></div>
|
<div class="step-icon"><i class="fas fa-server"></i></div>
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
<h5>Creating Proxy Host</h5>
|
<h5>Creating Proxy Host</h5>
|
||||||
<p class="step-status">Setting up NGINX proxy host configuration...</p>
|
<p class="step-status">Setting up NGINX proxy host configuration...</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
stepsContainer.appendChild(proxyStep);
|
stepsContainer.appendChild(proxyStep);
|
||||||
|
|
||||||
// Add Portainer connection check step
|
// Add Portainer connection check step
|
||||||
const portainerStep = document.createElement('div');
|
const portainerStep = document.createElement('div');
|
||||||
portainerStep.className = 'step-item';
|
portainerStep.className = 'step-item';
|
||||||
portainerStep.innerHTML = `
|
portainerStep.innerHTML = `
|
||||||
<div class="step-icon"><i class="fab fa-docker"></i></div>
|
<div class="step-icon"><i class="fab fa-docker"></i></div>
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
<h5>Checking Portainer Connection</h5>
|
<h5>Checking Portainer Connection</h5>
|
||||||
<p class="step-status">Verifying connection to Portainer...</p>
|
<p class="step-status">Verifying connection to Portainer...</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
stepsContainer.appendChild(portainerStep);
|
stepsContainer.appendChild(portainerStep);
|
||||||
|
|
||||||
// Add Docker Compose download step
|
// Add Docker Compose download step
|
||||||
const dockerComposeStep = document.createElement('div');
|
const dockerComposeStep = document.createElement('div');
|
||||||
dockerComposeStep.className = 'step-item';
|
dockerComposeStep.className = 'step-item';
|
||||||
dockerComposeStep.innerHTML = `
|
dockerComposeStep.innerHTML = `
|
||||||
<div class="step-icon"><i class="fas fa-file-code"></i></div>
|
<div class="step-icon"><i class="fas fa-file-code"></i></div>
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
<h5>Downloading Docker Compose</h5>
|
<h5>Downloading Docker Compose</h5>
|
||||||
<p class="step-status">Fetching docker-compose.yml from repository...</p>
|
<p class="step-status">Fetching docker-compose.yml from repository...</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
stepsContainer.appendChild(dockerComposeStep);
|
stepsContainer.appendChild(dockerComposeStep);
|
||||||
|
|
||||||
// Add Portainer stack deployment step
|
// Add Portainer stack deployment step
|
||||||
const stackDeployStep = document.createElement('div');
|
const stackDeployStep = document.createElement('div');
|
||||||
stackDeployStep.className = 'step-item';
|
stackDeployStep.className = 'step-item';
|
||||||
stackDeployStep.innerHTML = `
|
stackDeployStep.innerHTML = `
|
||||||
<div class="step-icon"><i class="fab fa-docker"></i></div>
|
<div class="step-icon"><i class="fab fa-docker"></i></div>
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
<h5>Deploying Stack</h5>
|
<h5>Deploying Stack</h5>
|
||||||
<p class="step-status">Launching your application stack...</p>
|
<p class="step-status">Launching your application stack...</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
stepsContainer.appendChild(stackDeployStep);
|
stepsContainer.appendChild(stackDeployStep);
|
||||||
|
|
||||||
// Add Save Instance Data step
|
// Add Save Instance Data step
|
||||||
const saveDataStep = document.createElement('div');
|
const saveDataStep = document.createElement('div');
|
||||||
saveDataStep.className = 'step-item';
|
saveDataStep.className = 'step-item';
|
||||||
saveDataStep.innerHTML = `
|
saveDataStep.innerHTML = `
|
||||||
<div class="step-icon"><i class="fas fa-save"></i></div>
|
<div class="step-icon"><i class="fas fa-save"></i></div>
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
<h5>Saving Instance Data</h5>
|
<h5>Saving Instance Data</h5>
|
||||||
<p class="step-status">Storing instance information...</p>
|
<p class="step-status">Storing instance information...</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
stepsContainer.appendChild(saveDataStep);
|
stepsContainer.appendChild(saveDataStep);
|
||||||
|
|
||||||
// Add Health Check step
|
// Add Health Check step
|
||||||
const healthStep = document.createElement('div');
|
const healthStep = document.createElement('div');
|
||||||
healthStep.className = 'step-item';
|
healthStep.className = 'step-item';
|
||||||
healthStep.innerHTML = `
|
healthStep.innerHTML = `
|
||||||
<div class="step-icon"><i class="fas fa-heartbeat"></i></div>
|
<div class="step-icon"><i class="fas fa-heartbeat"></i></div>
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
<h5>Health Check</h5>
|
<h5>Health Check</h5>
|
||||||
<p class="step-status">Verifying instance health...</p>
|
<p class="step-status">Verifying instance health...</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
stepsContainer.appendChild(healthStep);
|
stepsContainer.appendChild(healthStep);
|
||||||
|
|
||||||
// Add Authentication step
|
// Add Authentication step
|
||||||
const authStep = document.createElement('div');
|
const authStep = document.createElement('div');
|
||||||
authStep.className = 'step-item';
|
authStep.className = 'step-item';
|
||||||
authStep.innerHTML = `
|
authStep.innerHTML = `
|
||||||
<div class="step-icon"><i class="fas fa-key"></i></div>
|
<div class="step-icon"><i class="fas fa-key"></i></div>
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
<h5>Instance Authentication</h5>
|
<h5>Instance Authentication</h5>
|
||||||
<p class="step-status">Setting up instance authentication...</p>
|
<p class="step-status">Setting up instance authentication...</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
stepsContainer.appendChild(authStep);
|
stepsContainer.appendChild(authStep);
|
||||||
|
|
||||||
// Add Apply Company Information step
|
// Add Apply Company Information step
|
||||||
const companyStep = document.createElement('div');
|
const companyStep = document.createElement('div');
|
||||||
companyStep.className = 'step-item';
|
companyStep.className = 'step-item';
|
||||||
companyStep.innerHTML = `
|
companyStep.innerHTML = `
|
||||||
<div class="step-icon"><i class="fas fa-building"></i></div>
|
<div class="step-icon"><i class="fas fa-building"></i></div>
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
<h5>Apply Company Information</h5>
|
<h5>Apply Company Information</h5>
|
||||||
<p class="step-status">Configuring company details...</p>
|
<p class="step-status">Configuring company details...</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
stepsContainer.appendChild(companyStep);
|
stepsContainer.appendChild(companyStep);
|
||||||
|
|
||||||
// Add Apply Colors step
|
// Add Apply Colors step
|
||||||
const colorsStep = document.createElement('div');
|
const colorsStep = document.createElement('div');
|
||||||
colorsStep.className = 'step-item';
|
colorsStep.className = 'step-item';
|
||||||
colorsStep.innerHTML = `
|
colorsStep.innerHTML = `
|
||||||
<div class="step-icon"><i class="fas fa-palette"></i></div>
|
<div class="step-icon"><i class="fas fa-palette"></i></div>
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
<h5>Apply Colors</h5>
|
<h5>Apply Colors</h5>
|
||||||
<p class="step-status">Configuring color scheme...</p>
|
<p class="step-status">Configuring color scheme...</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
stepsContainer.appendChild(colorsStep);
|
stepsContainer.appendChild(colorsStep);
|
||||||
|
|
||||||
// Add Update Admin Credentials step
|
// Add Update Admin Credentials step
|
||||||
const credentialsStep = document.createElement('div');
|
const credentialsStep = document.createElement('div');
|
||||||
credentialsStep.className = 'step-item';
|
credentialsStep.className = 'step-item';
|
||||||
credentialsStep.innerHTML = `
|
credentialsStep.innerHTML = `
|
||||||
<div class="step-icon"><i class="fas fa-user-shield"></i></div>
|
<div class="step-icon"><i class="fas fa-user-shield"></i></div>
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
<h5>Update Admin Credentials</h5>
|
<h5>Update Admin Credentials</h5>
|
||||||
<p class="step-status">Setting up admin account...</p>
|
<p class="step-status">Setting up admin account...</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
stepsContainer.appendChild(credentialsStep);
|
stepsContainer.appendChild(credentialsStep);
|
||||||
|
|
||||||
// Add Copy SMTP Settings step
|
// Add Copy SMTP Settings step
|
||||||
const smtpStep = document.createElement('div');
|
const smtpStep = document.createElement('div');
|
||||||
smtpStep.className = 'step-item';
|
smtpStep.className = 'step-item';
|
||||||
smtpStep.innerHTML = `
|
smtpStep.innerHTML = `
|
||||||
<div class="step-icon"><i class="fas fa-envelope-open"></i></div>
|
<div class="step-icon"><i class="fas fa-envelope"></i></div>
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
<h5>Copy SMTP Settings</h5>
|
<h5>Copy SMTP Settings</h5>
|
||||||
<p class="step-status">Configuring email settings...</p>
|
<p class="step-status">Configuring email settings...</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
stepsContainer.appendChild(smtpStep);
|
stepsContainer.appendChild(smtpStep);
|
||||||
|
|
||||||
// Add Send Completion Email step
|
// Add Send Completion Email step
|
||||||
const emailStep = document.createElement('div');
|
const emailStep = document.createElement('div');
|
||||||
emailStep.className = 'step-item';
|
emailStep.className = 'step-item';
|
||||||
emailStep.innerHTML = `
|
emailStep.innerHTML = `
|
||||||
<div class="step-icon"><i class="fas fa-envelope"></i></div>
|
<div class="step-icon"><i class="fas fa-paper-plane"></i></div>
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
<h5>Send Completion Email</h5>
|
<h5>Send Completion Email</h5>
|
||||||
<p class="step-status">Sending notification to client...</p>
|
<p class="step-status">Sending completion notification...</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
stepsContainer.appendChild(emailStep);
|
stepsContainer.appendChild(emailStep);
|
||||||
|
|
||||||
// Add Download Launch Report step
|
// Add Download Report step
|
||||||
const reportStep = document.createElement('div');
|
const reportStep = document.createElement('div');
|
||||||
reportStep.className = 'step-item';
|
reportStep.className = 'step-item';
|
||||||
reportStep.innerHTML = `
|
reportStep.innerHTML = `
|
||||||
<div class="step-icon"><i class="fas fa-file-download"></i></div>
|
<div class="step-icon"><i class="fas fa-download"></i></div>
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
<h5>Download Launch Report</h5>
|
<h5>Download Launch Report</h5>
|
||||||
<p class="step-status">Preparing launch report...</p>
|
<p class="step-status">Preparing launch report...</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
stepsContainer.appendChild(reportStep);
|
stepsContainer.appendChild(reportStep);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startLaunch(data) {
|
async function startLaunch(data) {
|
||||||
@@ -467,7 +511,28 @@ async function startLaunch(data) {
|
|||||||
downloadButton.className = 'btn btn-sm btn-primary mt-2';
|
downloadButton.className = 'btn btn-sm btn-primary mt-2';
|
||||||
downloadButton.innerHTML = '<i class="fas fa-download me-1"></i> Download docker-compose.yml';
|
downloadButton.innerHTML = '<i class="fas fa-download me-1"></i> Download docker-compose.yml';
|
||||||
downloadButton.onclick = () => {
|
downloadButton.onclick = () => {
|
||||||
const blob = new Blob([dockerComposeResult.content], { type: 'text/yaml' });
|
// Generate the modified docker-compose content with updated volume names
|
||||||
|
let modifiedContent = dockerComposeResult.content;
|
||||||
|
const stackName = generateStackName(data.port);
|
||||||
|
|
||||||
|
// Extract timestamp from stack name (format: docupulse_PORT_TIMESTAMP)
|
||||||
|
const stackNameParts = stackName.split('_');
|
||||||
|
if (stackNameParts.length >= 3) {
|
||||||
|
const timestamp = stackNameParts.slice(2).join('_'); // Get everything after port
|
||||||
|
const baseName = `docupulse_${data.port}_${timestamp}`;
|
||||||
|
|
||||||
|
// Replace volume names to match stack naming convention
|
||||||
|
modifiedContent = modifiedContent.replace(
|
||||||
|
/name: docupulse_\$\{PORT:-10335\}_postgres_data/g,
|
||||||
|
`name: ${baseName}_postgres_data`
|
||||||
|
);
|
||||||
|
modifiedContent = modifiedContent.replace(
|
||||||
|
/name: docupulse_\$\{PORT:-10335\}_uploads/g,
|
||||||
|
`name: ${baseName}_uploads`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([modifiedContent], { type: 'text/yaml' });
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
@@ -499,7 +564,8 @@ async function startLaunch(data) {
|
|||||||
`;
|
`;
|
||||||
stackDeployStepElement.querySelector('.step-content').appendChild(stackProgressDiv);
|
stackDeployStepElement.querySelector('.step-content').appendChild(stackProgressDiv);
|
||||||
|
|
||||||
const stackResult = await deployStack(dockerComposeResult.content, `docupulse_${data.port}`, data.port);
|
const stackName = generateStackName(data.port);
|
||||||
|
const stackResult = await deployStack(dockerComposeResult.content, stackName, data.port);
|
||||||
launchReport.steps.push({
|
launchReport.steps.push({
|
||||||
step: 'Stack Deployment',
|
step: 'Stack Deployment',
|
||||||
status: stackResult.success ? 'success' : 'error',
|
status: stackResult.success ? 'success' : 'error',
|
||||||
@@ -509,7 +575,11 @@ async function startLaunch(data) {
|
|||||||
// Handle different stack deployment scenarios
|
// Handle different stack deployment scenarios
|
||||||
if (!stackResult.success) {
|
if (!stackResult.success) {
|
||||||
// Check if this is a timeout but the stack might still be deploying
|
// Check if this is a timeout but the stack might still be deploying
|
||||||
if (stackResult.error && stackResult.error.includes('timed out')) {
|
if (stackResult.error && (
|
||||||
|
stackResult.error.includes('timed out') ||
|
||||||
|
stackResult.error.includes('504 Gateway Time-out') ||
|
||||||
|
stackResult.error.includes('504 Gateway Timeout')
|
||||||
|
)) {
|
||||||
console.log('Stack deployment timed out, but may still be in progress');
|
console.log('Stack deployment timed out, but may still be in progress');
|
||||||
|
|
||||||
// Update the step to show warning instead of error
|
// Update the step to show warning instead of error
|
||||||
@@ -530,7 +600,7 @@ async function startLaunch(data) {
|
|||||||
|
|
||||||
// Continue with the process using the available data
|
// Continue with the process using the available data
|
||||||
stackResult.data = stackResult.data || {
|
stackResult.data = stackResult.data || {
|
||||||
name: `docupulse_${data.port}`,
|
name: stackName,
|
||||||
status: 'creating',
|
status: 'creating',
|
||||||
id: null
|
id: null
|
||||||
};
|
};
|
||||||
@@ -553,6 +623,19 @@ async function startLaunch(data) {
|
|||||||
// Add stack details
|
// Add stack details
|
||||||
const stackDetails = document.createElement('div');
|
const stackDetails = document.createElement('div');
|
||||||
stackDetails.className = 'mt-3';
|
stackDetails.className = 'mt-3';
|
||||||
|
|
||||||
|
// Calculate the volume names based on the stack name
|
||||||
|
const stackNameParts = stackResult.data.name.split('_');
|
||||||
|
let volumeNames = [];
|
||||||
|
if (stackNameParts.length >= 3) {
|
||||||
|
const timestamp = stackNameParts.slice(2).join('_');
|
||||||
|
const baseName = `docupulse_${data.port}_${timestamp}`;
|
||||||
|
volumeNames = [
|
||||||
|
`${baseName}_postgres_data`,
|
||||||
|
`${baseName}_uploads`
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
stackDetails.innerHTML = `
|
stackDetails.innerHTML = `
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -582,9 +665,25 @@ async function startLaunch(data) {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Volume Names</td>
|
||||||
|
<td>
|
||||||
|
<div class="small">
|
||||||
|
${volumeNames.length > 0 ? volumeNames.map(name =>
|
||||||
|
`<code class="text-primary">${name}</code>`
|
||||||
|
).join('<br>') : 'Using default volume names'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
${volumeNames.length > 0 ? `
|
||||||
|
<div class="alert alert-info mt-3">
|
||||||
|
<i class="fas fa-info-circle me-2"></i>
|
||||||
|
<strong>Volume Naming Convention:</strong> Volumes have been named using the same timestamp as the stack for easy identification and management.
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -1248,6 +1347,182 @@ Thank you for choosing DocuPulse!
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Function to handle instance updates
|
||||||
|
async function startUpdate(data) {
|
||||||
|
console.log('Starting instance update:', data);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update the header to reflect this is an update
|
||||||
|
const headerTitle = document.querySelector('.header h1');
|
||||||
|
const headerDescription = document.querySelector('.header p');
|
||||||
|
if (headerTitle) headerTitle.textContent = 'Updating Instance';
|
||||||
|
if (headerDescription) headerDescription.textContent = 'Updating your DocuPulse instance with the latest version';
|
||||||
|
|
||||||
|
// Initialize launch report for update
|
||||||
|
const launchReport = {
|
||||||
|
type: 'update',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
instanceId: data.instanceId,
|
||||||
|
repository: data.repository,
|
||||||
|
branch: data.branch,
|
||||||
|
steps: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 1: Check Portainer Connection
|
||||||
|
await updateStep(1, 'Checking Portainer Connection', 'Verifying connection to Portainer...');
|
||||||
|
const portainerResult = await checkPortainerConnection();
|
||||||
|
if (!portainerResult.success) {
|
||||||
|
throw new Error(`Portainer connection failed: ${portainerResult.error}`);
|
||||||
|
}
|
||||||
|
launchReport.steps.push({
|
||||||
|
step: 'Portainer Connection',
|
||||||
|
status: 'success',
|
||||||
|
details: portainerResult
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Download Docker Compose
|
||||||
|
await updateStep(2, 'Downloading Docker Compose', 'Fetching docker-compose.yml from repository...');
|
||||||
|
const dockerComposeResult = await downloadDockerCompose(data.repository, data.branch);
|
||||||
|
if (!dockerComposeResult.success) {
|
||||||
|
throw new Error(`Failed to download Docker Compose: ${dockerComposeResult.error}`);
|
||||||
|
}
|
||||||
|
launchReport.steps.push({
|
||||||
|
step: 'Docker Compose Download',
|
||||||
|
status: 'success',
|
||||||
|
details: dockerComposeResult
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: Deploy Updated Stack
|
||||||
|
await updateStep(3, 'Deploying Updated Stack', 'Deploying the updated application stack...');
|
||||||
|
|
||||||
|
// Get the existing instance information to extract port
|
||||||
|
const instanceResponse = await fetch(`/api/instances/${data.instanceId}`);
|
||||||
|
if (!instanceResponse.ok) {
|
||||||
|
throw new Error('Failed to get instance information');
|
||||||
|
}
|
||||||
|
const instanceData = await instanceResponse.json();
|
||||||
|
const port = instanceData.instance.name; // Assuming the instance name is the port
|
||||||
|
|
||||||
|
// Generate new stack name with timestamp
|
||||||
|
const newStackName = generateStackName(port);
|
||||||
|
|
||||||
|
const stackResult = await deployStack(dockerComposeResult.content, newStackName, port);
|
||||||
|
if (!stackResult.success) {
|
||||||
|
throw new Error(`Failed to deploy updated stack: ${stackResult.error}`);
|
||||||
|
}
|
||||||
|
launchReport.steps.push({
|
||||||
|
step: 'Stack Deployment',
|
||||||
|
status: 'success',
|
||||||
|
details: stackResult
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 4: Update Instance Data
|
||||||
|
await updateStep(4, 'Updating Instance Data', 'Updating instance information...');
|
||||||
|
const updateData = {
|
||||||
|
name: instanceData.instance.name,
|
||||||
|
port: port,
|
||||||
|
domains: instanceData.instance.main_url ? [instanceData.instance.main_url.replace(/^https?:\/\//, '')] : [],
|
||||||
|
stack_id: stackResult.data.id || null,
|
||||||
|
stack_name: newStackName,
|
||||||
|
status: stackResult.data.status,
|
||||||
|
repository: data.repository,
|
||||||
|
branch: data.branch,
|
||||||
|
deployed_version: dockerComposeResult.latest_tag || dockerComposeResult.commit_hash || 'unknown',
|
||||||
|
deployed_branch: data.branch,
|
||||||
|
payment_plan: instanceData.instance.payment_plan || 'Basic'
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveResult = await saveInstanceData(updateData);
|
||||||
|
if (!saveResult.success) {
|
||||||
|
throw new Error(`Failed to update instance data: ${saveResult.error}`);
|
||||||
|
}
|
||||||
|
launchReport.steps.push({
|
||||||
|
step: 'Instance Data Update',
|
||||||
|
status: 'success',
|
||||||
|
details: saveResult
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 5: Health Check
|
||||||
|
await updateStep(5, 'Health Check', 'Verifying updated instance health...');
|
||||||
|
const healthResult = await checkInstanceHealth(instanceData.instance.main_url);
|
||||||
|
if (!healthResult.success) {
|
||||||
|
throw new Error(`Health check failed: ${healthResult.error}`);
|
||||||
|
}
|
||||||
|
launchReport.steps.push({
|
||||||
|
step: 'Health Check',
|
||||||
|
status: 'success',
|
||||||
|
details: healthResult
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update completed successfully
|
||||||
|
await updateStep(6, 'Update Complete', 'Instance has been successfully updated!');
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
const successStep = document.querySelectorAll('.step-item')[5];
|
||||||
|
successStep.classList.remove('active');
|
||||||
|
successStep.classList.add('completed');
|
||||||
|
successStep.querySelector('.step-status').textContent = 'Instance updated successfully!';
|
||||||
|
|
||||||
|
// Add success details
|
||||||
|
const successDetails = document.createElement('div');
|
||||||
|
successDetails.className = 'mt-3';
|
||||||
|
|
||||||
|
// Calculate the volume names based on the stack name
|
||||||
|
const stackNameParts = newStackName.split('_');
|
||||||
|
let volumeNames = [];
|
||||||
|
if (stackNameParts.length >= 3) {
|
||||||
|
const timestamp = stackNameParts.slice(2).join('_');
|
||||||
|
const baseName = `docupulse_${port}_${timestamp}`;
|
||||||
|
volumeNames = [
|
||||||
|
`${baseName}_postgres_data`,
|
||||||
|
`${baseName}_uploads`
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
successDetails.innerHTML = `
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<h6><i class="fas fa-check-circle me-2"></i>Update Completed Successfully!</h6>
|
||||||
|
<p class="mb-2">Your instance has been updated with the latest version from the repository.</p>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<strong>Repository:</strong> ${data.repository}<br>
|
||||||
|
<strong>Branch:</strong> ${data.branch}<br>
|
||||||
|
<strong>New Version:</strong> ${dockerComposeResult.latest_tag || dockerComposeResult.commit_hash || 'unknown'}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<strong>New Stack Name:</strong> ${newStackName}<br>
|
||||||
|
<strong>Instance URL:</strong> <a href="${instanceData.instance.main_url}" target="_blank">${instanceData.instance.main_url}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${volumeNames.length > 0 ? `
|
||||||
|
<div class="mt-3">
|
||||||
|
<strong>New Volume Names:</strong>
|
||||||
|
<div class="small mt-1">
|
||||||
|
${volumeNames.map(name => `<code class="text-primary">${name}</code>`).join('<br>')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
successStep.querySelector('.step-content').appendChild(successDetails);
|
||||||
|
|
||||||
|
// Add button to return to instances page
|
||||||
|
const returnButton = document.createElement('button');
|
||||||
|
returnButton.className = 'btn btn-primary mt-3';
|
||||||
|
returnButton.innerHTML = '<i class="fas fa-arrow-left me-2"></i>Return to Instances';
|
||||||
|
returnButton.onclick = () => window.location.href = '/instances';
|
||||||
|
successStep.querySelector('.step-content').appendChild(returnButton);
|
||||||
|
|
||||||
|
// Store the update report
|
||||||
|
sessionStorage.setItem('instanceUpdateReport', JSON.stringify(launchReport));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update failed:', error);
|
||||||
|
await updateStep(6, 'Update Failed', `Error: ${error.message}`);
|
||||||
|
showError(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function checkDNSRecords(domains) {
|
async function checkDNSRecords(domains) {
|
||||||
const maxRetries = 30; // 30 retries * 10 seconds = 5 minutes
|
const maxRetries = 30; // 30 retries * 10 seconds = 5 minutes
|
||||||
const baseDelay = 10000; // 10 seconds base delay
|
const baseDelay = 10000; // 10 seconds base delay
|
||||||
@@ -2644,7 +2919,34 @@ async function deployStack(dockerComposeContent, stackName, port) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update volume names in docker-compose content to match stack naming convention
|
||||||
|
let modifiedDockerComposeContent = dockerComposeContent;
|
||||||
|
|
||||||
|
// Extract timestamp from stack name (format: docupulse_PORT_TIMESTAMP)
|
||||||
|
const stackNameParts = stackName.split('_');
|
||||||
|
if (stackNameParts.length >= 3) {
|
||||||
|
const timestamp = stackNameParts.slice(2).join('_'); // Get everything after port
|
||||||
|
const baseName = `docupulse_${port}_${timestamp}`;
|
||||||
|
|
||||||
|
// Replace volume names to match stack naming convention
|
||||||
|
modifiedDockerComposeContent = modifiedDockerComposeContent.replace(
|
||||||
|
/name: docupulse_\$\{PORT:-10335\}_postgres_data/g,
|
||||||
|
`name: ${baseName}_postgres_data`
|
||||||
|
);
|
||||||
|
modifiedDockerComposeContent = modifiedDockerComposeContent.replace(
|
||||||
|
/name: docupulse_\$\{PORT:-10335\}_uploads/g,
|
||||||
|
`name: ${baseName}_uploads`
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Updated volume names to match stack naming convention: ${baseName}`);
|
||||||
|
}
|
||||||
|
|
||||||
// First, attempt to deploy the stack
|
// First, attempt to deploy the stack
|
||||||
|
console.log('Making stack deployment request to /api/admin/deploy-stack');
|
||||||
|
console.log('Stack name:', stackName);
|
||||||
|
console.log('Port:', port);
|
||||||
|
console.log('Modified docker-compose content length:', modifiedDockerComposeContent.length);
|
||||||
|
|
||||||
const response = await fetch('/api/admin/deploy-stack', {
|
const response = await fetch('/api/admin/deploy-stack', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -2652,8 +2954,8 @@ async function deployStack(dockerComposeContent, stackName, port) {
|
|||||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: `docupulse_${port}`,
|
name: stackName,
|
||||||
StackFileContent: dockerComposeContent,
|
StackFileContent: modifiedDockerComposeContent,
|
||||||
Env: [
|
Env: [
|
||||||
{
|
{
|
||||||
name: 'PORT',
|
name: 'PORT',
|
||||||
@@ -2712,13 +3014,17 @@ async function deployStack(dockerComposeContent, stackName, port) {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('Response status:', response.status);
|
||||||
|
console.log('Response ok:', response.ok);
|
||||||
|
console.log('Response headers:', Object.fromEntries(response.headers.entries()));
|
||||||
|
|
||||||
// Handle 504 Gateway Timeout as successful initiation
|
// Handle 504 Gateway Timeout as successful initiation
|
||||||
if (response.status === 504) {
|
if (response.status === 504) {
|
||||||
console.log('Received 504 Gateway Timeout - stack creation may still be in progress');
|
console.log('Received 504 Gateway Timeout - stack creation may still be in progress');
|
||||||
|
|
||||||
// Update progress to show that we're now polling
|
// Update progress to show that we're now polling
|
||||||
const progressBar = document.getElementById('stackProgress');
|
const progressBar = document.getElementById('launchProgress');
|
||||||
const progressText = document.getElementById('stackProgressText');
|
const progressText = document.getElementById('stepDescription');
|
||||||
if (progressBar && progressText) {
|
if (progressBar && progressText) {
|
||||||
progressBar.style.width = '25%';
|
progressBar.style.width = '25%';
|
||||||
progressBar.textContent = '25%';
|
progressBar.textContent = '25%';
|
||||||
@@ -2727,13 +3033,50 @@ async function deployStack(dockerComposeContent, stackName, port) {
|
|||||||
|
|
||||||
// Start polling immediately since the stack creation was initiated
|
// Start polling immediately since the stack creation was initiated
|
||||||
console.log('Starting to poll for stack status after 504 timeout...');
|
console.log('Starting to poll for stack status after 504 timeout...');
|
||||||
const pollResult = await pollStackStatus(`docupulse_${port}`, 15 * 60 * 1000); // 15 minutes max
|
const pollResult = await pollStackStatus(stackName, 15 * 60 * 1000); // 15 minutes max
|
||||||
return pollResult;
|
return pollResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json();
|
let errorMessage = 'Failed to deploy stack';
|
||||||
throw new Error(error.error || 'Failed to deploy stack');
|
console.log('Response not ok, status:', response.status);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
errorMessage = errorData.error || errorMessage;
|
||||||
|
console.log('Parsed error data:', errorData);
|
||||||
|
} catch (parseError) {
|
||||||
|
console.log('Failed to parse JSON error, trying text:', parseError);
|
||||||
|
// If JSON parsing fails, try to get text content
|
||||||
|
try {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.log('Error text content:', errorText);
|
||||||
|
if (errorText.includes('504 Gateway Time-out') || errorText.includes('504 Gateway Timeout')) {
|
||||||
|
console.log('Received 504 Gateway Timeout - stack creation may still be in progress');
|
||||||
|
|
||||||
|
// Update progress to show that we're now polling
|
||||||
|
const progressBar = document.getElementById('launchProgress');
|
||||||
|
const progressText = document.getElementById('stepDescription');
|
||||||
|
if (progressBar && progressText) {
|
||||||
|
progressBar.style.width = '25%';
|
||||||
|
progressBar.textContent = '25%';
|
||||||
|
progressText.textContent = 'Stack creation initiated (timed out, but continuing to monitor)...';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start polling immediately since the stack creation was initiated
|
||||||
|
console.log('Starting to poll for stack status after 504 timeout...');
|
||||||
|
const pollResult = await pollStackStatus(stackName, 15 * 60 * 1000); // 15 minutes max
|
||||||
|
return pollResult;
|
||||||
|
} else {
|
||||||
|
errorMessage = `HTTP ${response.status}: ${errorText}`;
|
||||||
|
}
|
||||||
|
} catch (textError) {
|
||||||
|
console.log('Failed to get error text:', textError);
|
||||||
|
errorMessage = `HTTP ${response.status}: Failed to parse response`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('Throwing error:', errorMessage);
|
||||||
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -2742,7 +3085,7 @@ async function deployStack(dockerComposeContent, stackName, port) {
|
|||||||
// If stack is being created, poll for status
|
// If stack is being created, poll for status
|
||||||
if (result.data.status === 'creating') {
|
if (result.data.status === 'creating') {
|
||||||
console.log('Stack is being created, polling for status...');
|
console.log('Stack is being created, polling for status...');
|
||||||
const pollResult = await pollStackStatus(`docupulse_${port}`, 10 * 60 * 1000); // 10 minutes max
|
const pollResult = await pollStackStatus(stackName, 10 * 60 * 1000); // 10 minutes max
|
||||||
return pollResult;
|
return pollResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2754,6 +3097,30 @@ async function deployStack(dockerComposeContent, stackName, port) {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deploying stack:', error);
|
console.error('Error deploying stack:', error);
|
||||||
|
|
||||||
|
// Check if this is a 504 timeout error that should be handled as a success
|
||||||
|
if (error.message && (
|
||||||
|
error.message.includes('504 Gateway Time-out') ||
|
||||||
|
error.message.includes('504 Gateway Timeout') ||
|
||||||
|
error.message.includes('timed out')
|
||||||
|
)) {
|
||||||
|
console.log('Detected 504 timeout in catch block - treating as successful initiation');
|
||||||
|
|
||||||
|
// Update progress to show that we're now polling
|
||||||
|
const progressBar = document.getElementById('launchProgress');
|
||||||
|
const progressText = document.getElementById('stepDescription');
|
||||||
|
if (progressBar && progressText) {
|
||||||
|
progressBar.style.width = '25%';
|
||||||
|
progressBar.textContent = '25%';
|
||||||
|
progressText.textContent = 'Stack creation initiated (timed out, but continuing to monitor)...';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start polling immediately since the stack creation was initiated
|
||||||
|
console.log('Starting to poll for stack status after 504 timeout from catch block...');
|
||||||
|
const pollResult = await pollStackStatus(stackName, 15 * 60 * 1000); // 15 minutes max
|
||||||
|
return pollResult;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error.message
|
error: error.message
|
||||||
@@ -2784,8 +3151,8 @@ async function pollStackStatus(stackName, maxWaitTime = 15 * 60 * 1000) {
|
|||||||
console.log(`Starting to poll stack status for: ${stackName} (max wait: ${maxWaitTime / 1000}s)`);
|
console.log(`Starting to poll stack status for: ${stackName} (max wait: ${maxWaitTime / 1000}s)`);
|
||||||
|
|
||||||
// Update progress indicator
|
// Update progress indicator
|
||||||
const progressBar = document.getElementById('stackProgress');
|
const progressBar = document.getElementById('launchProgress');
|
||||||
const progressText = document.getElementById('stackProgressText');
|
const progressText = document.getElementById('stepDescription');
|
||||||
|
|
||||||
while (Date.now() - startTime < maxWaitTime) {
|
while (Date.now() - startTime < maxWaitTime) {
|
||||||
attempts++;
|
attempts++;
|
||||||
@@ -2917,4 +3284,16 @@ async function pollStackStatus(stackName, maxWaitTime = 15 * 60 * 1000) {
|
|||||||
status: lastKnownStatus
|
status: lastKnownStatus
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to generate unique stack names with timestamp
|
||||||
|
function generateStackName(port) {
|
||||||
|
const now = new Date();
|
||||||
|
const timestamp = now.getFullYear().toString() +
|
||||||
|
(now.getMonth() + 1).toString().padStart(2, '0') +
|
||||||
|
now.getDate().toString().padStart(2, '0') + '_' +
|
||||||
|
now.getHours().toString().padStart(2, '0') +
|
||||||
|
now.getMinutes().toString().padStart(2, '0') +
|
||||||
|
now.getSeconds().toString().padStart(2, '0');
|
||||||
|
return `docupulse_${port}_${timestamp}`;
|
||||||
|
}
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
{% block title %}Support Articles - DocuPulse{% endblock %}
|
{% block title %}Support Articles - DocuPulse{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<link href="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.css" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
@@ -227,6 +231,7 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p>Are you sure you want to delete this article? This action cannot be undone.</p>
|
<p>Are you sure you want to delete this article? This action cannot be undone.</p>
|
||||||
<p class="text-muted" id="deleteArticleTitle"></p>
|
<p class="text-muted" id="deleteArticleTitle"></p>
|
||||||
|
<input type="hidden" id="deleteArticleId" value="">
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
@@ -295,7 +300,15 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
|
|
||||||
function loadArticles() {
|
function loadArticles() {
|
||||||
fetch('/api/admin/help-articles')
|
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 => {
|
.then(data => {
|
||||||
const articlesList = document.getElementById('articlesList');
|
const articlesList = document.getElementById('articlesList');
|
||||||
articlesList.innerHTML = '';
|
articlesList.innerHTML = '';
|
||||||
@@ -377,7 +390,17 @@ function createArticle() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
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 => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
const modal = bootstrap.Modal.getInstance(document.getElementById('createArticleModal'));
|
const modal = bootstrap.Modal.getInstance(document.getElementById('createArticleModal'));
|
||||||
@@ -398,7 +421,15 @@ function createArticle() {
|
|||||||
|
|
||||||
function editArticle(articleId) {
|
function editArticle(articleId) {
|
||||||
fetch(`/api/admin/help-articles/${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 => {
|
.then(data => {
|
||||||
document.getElementById('editArticleId').value = data.article.id;
|
document.getElementById('editArticleId').value = data.article.id;
|
||||||
document.getElementById('editArticleTitle').value = data.article.title;
|
document.getElementById('editArticleTitle').value = data.article.title;
|
||||||
@@ -426,7 +457,15 @@ function updateArticle() {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: formData
|
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 => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
const modal = bootstrap.Modal.getInstance(document.getElementById('editArticleModal'));
|
const modal = bootstrap.Modal.getInstance(document.getElementById('editArticleModal'));
|
||||||
@@ -457,7 +496,15 @@ function deleteArticle() {
|
|||||||
fetch(`/api/admin/help-articles/${articleId}`, {
|
fetch(`/api/admin/help-articles/${articleId}`, {
|
||||||
method: 'DELETE'
|
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 => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
const modal = bootstrap.Modal.getInstance(document.getElementById('deleteArticleModal'));
|
const modal = bootstrap.Modal.getInstance(document.getElementById('deleteArticleModal'));
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<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="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('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 }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/home.css') }}?v={{ 'css/home.css'|asset_version }}">
|
||||||
<style>
|
<style>
|
||||||
.admin-link {
|
.admin-link {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,9 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ header(
|
{{ header(
|
||||||
title="Launching Instance",
|
title=is_update and "Updating Instance" or "Launching Instance",
|
||||||
description="Setting up your new DocuPulse instance",
|
description=is_update and "Updating your DocuPulse instance with the latest version" or "Setting up your new DocuPulse instance",
|
||||||
icon="fa-rocket"
|
icon="fa-arrow-up" if is_update else "fa-rocket"
|
||||||
) }}
|
) }}
|
||||||
|
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
@@ -78,6 +78,12 @@
|
|||||||
|
|
||||||
// Pass CSRF token to JavaScript
|
// Pass CSRF token to JavaScript
|
||||||
window.csrfToken = '{{ csrf_token }}';
|
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>
|
||||||
<script src="{{ url_for('static', filename='js/launch_progress.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/launch_progress.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<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="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('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>
|
<style>
|
||||||
.about-section {
|
.about-section {
|
||||||
padding: 80px 0;
|
padding: 80px 0;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<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="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('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>
|
<style>
|
||||||
.careers-section {
|
.careers-section {
|
||||||
padding: 80px 0;
|
padding: 80px 0;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<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="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('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>
|
<style>
|
||||||
.legal-section {
|
.legal-section {
|
||||||
padding: 80px 0;
|
padding: 80px 0;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<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="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('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>
|
<style>
|
||||||
.contact-section {
|
.contact-section {
|
||||||
padding: 80px 0;
|
padding: 80px 0;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<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="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('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>
|
<style>
|
||||||
/* Enhanced Features Page Styles */
|
/* Enhanced Features Page Styles */
|
||||||
.hero-section {
|
.hero-section {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<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="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('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>
|
<style>
|
||||||
.legal-section {
|
.legal-section {
|
||||||
padding: 80px 0;
|
padding: 80px 0;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<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="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('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>
|
<style>
|
||||||
.help-section {
|
.help-section {
|
||||||
padding: 80px 0;
|
padding: 80px 0;
|
||||||
@@ -415,23 +416,5 @@
|
|||||||
{% include 'components/footer_nav.html' %}
|
{% include 'components/footer_nav.html' %}
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<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="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('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>
|
<style>
|
||||||
.category-nav {
|
.category-nav {
|
||||||
background: var(--white);
|
background: var(--white);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<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="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('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>
|
<style>
|
||||||
.press-section {
|
.press-section {
|
||||||
padding: 80px 0;
|
padding: 80px 0;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<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="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('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>
|
<style>
|
||||||
.pricing-section {
|
.pricing-section {
|
||||||
padding: 80px 0;
|
padding: 80px 0;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<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="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('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>
|
<style>
|
||||||
.legal-section {
|
.legal-section {
|
||||||
padding: 80px 0;
|
padding: 80px 0;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<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="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('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>
|
<style>
|
||||||
.security-section {
|
.security-section {
|
||||||
padding: 80px 0;
|
padding: 80px 0;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<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="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('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>
|
<style>
|
||||||
.legal-section {
|
.legal-section {
|
||||||
padding: 80px 0;
|
padding: 80px 0;
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user