Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 56d94a06ce | |||
| de3880e880 | |||
| 0466b11c71 | |||
| e519dc3a8b | |||
| ac9f002365 | |||
| 8de74827f2 | |||
| 81552bc5ec | |||
| 490bc05a9e | |||
| cc699506d3 | |||
| 84da2eb489 | |||
| a9a61c98f5 | |||
| 4678022c7b | |||
| ca2d2e6587 | |||
| 912f97490c | |||
| d7f5809771 |
@@ -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
|
||||
Binary file not shown.
@@ -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_BRANCH=${GIT_BRANCH:-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:
|
||||
- docupulse_uploads:/app/uploads
|
||||
depends_on:
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""add portainer stack fields to instances
|
||||
|
||||
Revision ID: 9206bf87bb8e
|
||||
Revises: add_quota_fields
|
||||
Create Date: 2025-06-24 14:02:17.375785
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '9206bf87bb8e'
|
||||
down_revision = 'add_quota_fields'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
|
||||
# Check if columns already exist
|
||||
result = conn.execute(text("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'instances'
|
||||
AND column_name IN ('portainer_stack_id', 'portainer_stack_name')
|
||||
"""))
|
||||
existing_columns = [row[0] for row in result.fetchall()]
|
||||
|
||||
# Add portainer stack columns if they don't exist
|
||||
with op.batch_alter_table('instances', schema=None) as batch_op:
|
||||
if 'portainer_stack_id' not in existing_columns:
|
||||
batch_op.add_column(sa.Column('portainer_stack_id', sa.String(length=100), nullable=True))
|
||||
if 'portainer_stack_name' not in existing_columns:
|
||||
batch_op.add_column(sa.Column('portainer_stack_name', sa.String(length=100), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('instances', schema=None) as batch_op:
|
||||
batch_op.drop_column('portainer_stack_name')
|
||||
batch_op.drop_column('portainer_stack_id')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -20,11 +20,21 @@ def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('email_template')
|
||||
op.drop_table('notification')
|
||||
|
||||
# Check if columns already exist before adding them
|
||||
connection = op.get_bind()
|
||||
inspector = sa.inspect(connection)
|
||||
existing_columns = [col['name'] for col in inspector.get_columns('instances')]
|
||||
|
||||
with op.batch_alter_table('instances', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('deployed_version', sa.String(length=50), nullable=True))
|
||||
batch_op.add_column(sa.Column('deployed_branch', sa.String(length=100), nullable=True))
|
||||
batch_op.add_column(sa.Column('latest_version', sa.String(length=50), nullable=True))
|
||||
batch_op.add_column(sa.Column('version_checked_at', sa.DateTime(), nullable=True))
|
||||
if 'deployed_version' not in existing_columns:
|
||||
batch_op.add_column(sa.Column('deployed_version', sa.String(length=50), nullable=True))
|
||||
if 'deployed_branch' not in existing_columns:
|
||||
batch_op.add_column(sa.Column('deployed_branch', sa.String(length=100), nullable=True))
|
||||
if 'latest_version' not in existing_columns:
|
||||
batch_op.add_column(sa.Column('latest_version', sa.String(length=50), nullable=True))
|
||||
if 'version_checked_at' not in existing_columns:
|
||||
batch_op.add_column(sa.Column('version_checked_at', sa.DateTime(), nullable=True))
|
||||
|
||||
with op.batch_alter_table('room_file', schema=None) as batch_op:
|
||||
batch_op.drop_constraint(batch_op.f('fk_room_file_deleted_by_user'), type_='foreignkey')
|
||||
@@ -37,11 +47,20 @@ def downgrade():
|
||||
with op.batch_alter_table('room_file', schema=None) as batch_op:
|
||||
batch_op.create_foreign_key(batch_op.f('fk_room_file_deleted_by_user'), 'user', ['deleted_by'], ['id'])
|
||||
|
||||
# Check if columns exist before dropping them
|
||||
connection = op.get_bind()
|
||||
inspector = sa.inspect(connection)
|
||||
existing_columns = [col['name'] for col in inspector.get_columns('instances')]
|
||||
|
||||
with op.batch_alter_table('instances', schema=None) as batch_op:
|
||||
batch_op.drop_column('version_checked_at')
|
||||
batch_op.drop_column('latest_version')
|
||||
batch_op.drop_column('deployed_branch')
|
||||
batch_op.drop_column('deployed_version')
|
||||
if 'version_checked_at' in existing_columns:
|
||||
batch_op.drop_column('version_checked_at')
|
||||
if 'latest_version' in existing_columns:
|
||||
batch_op.drop_column('latest_version')
|
||||
if 'deployed_branch' in existing_columns:
|
||||
batch_op.drop_column('deployed_branch')
|
||||
if 'deployed_version' in existing_columns:
|
||||
batch_op.drop_column('deployed_version')
|
||||
|
||||
op.create_table('notification',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
|
||||
@@ -528,6 +528,9 @@ class Instance(db.Model):
|
||||
status = db.Column(db.String(20), nullable=False, default='inactive')
|
||||
status_details = db.Column(db.Text, nullable=True)
|
||||
connection_token = db.Column(db.String(64), unique=True, nullable=True)
|
||||
# Portainer integration fields
|
||||
portainer_stack_id = db.Column(db.String(100), nullable=True) # Portainer stack ID
|
||||
portainer_stack_name = db.Column(db.String(100), nullable=True) # Portainer stack name
|
||||
# Version tracking fields
|
||||
deployed_version = db.Column(db.String(50), nullable=True) # Current deployed version (commit hash or tag)
|
||||
deployed_branch = db.Column(db.String(100), nullable=True) # Branch that was deployed
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,8 @@
|
||||
from flask import Blueprint, jsonify, request
|
||||
from flask_login import login_required, current_user
|
||||
from models import db, Room, RoomFile, User, DocuPulseSettings, HelpArticle
|
||||
from extensions import csrf
|
||||
from utils.event_logger import log_event
|
||||
import os
|
||||
from datetime import datetime
|
||||
import json
|
||||
@@ -258,6 +260,7 @@ def get_usage_stats():
|
||||
|
||||
@admin.route('/api/admin/help-articles/<int:article_id>', methods=['PUT'])
|
||||
@login_required
|
||||
@csrf.exempt
|
||||
def update_help_article(article_id):
|
||||
"""Update a help article"""
|
||||
if not current_user.is_admin:
|
||||
@@ -310,6 +313,7 @@ def update_help_article(article_id):
|
||||
|
||||
@admin.route('/api/admin/help-articles/<int:article_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
@csrf.exempt
|
||||
def delete_help_article(article_id):
|
||||
"""Delete a help article"""
|
||||
if not current_user.is_admin:
|
||||
@@ -342,6 +346,7 @@ def delete_help_article(article_id):
|
||||
# Help Articles API endpoints
|
||||
@admin.route('/api/admin/help-articles', methods=['GET'])
|
||||
@login_required
|
||||
@csrf.exempt
|
||||
def get_help_articles():
|
||||
"""Get all help articles"""
|
||||
if not current_user.is_admin:
|
||||
@@ -367,6 +372,7 @@ def get_help_articles():
|
||||
|
||||
@admin.route('/api/admin/help-articles', methods=['POST'])
|
||||
@login_required
|
||||
@csrf.exempt
|
||||
def create_help_article():
|
||||
"""Create a new help article"""
|
||||
if not current_user.is_admin:
|
||||
@@ -420,6 +426,7 @@ def create_help_article():
|
||||
|
||||
@admin.route('/api/admin/help-articles/<int:article_id>', methods=['GET'])
|
||||
@login_required
|
||||
@csrf.exempt
|
||||
def get_help_article(article_id):
|
||||
"""Get a specific help article"""
|
||||
if not current_user.is_admin:
|
||||
@@ -675,4 +682,54 @@ def update_pricing_plan_status(plan_id):
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@admin.route('/api/admin/pricing-plans', methods=['GET'])
|
||||
@login_required
|
||||
def get_pricing_plans():
|
||||
"""Get all active pricing plans for instance launch"""
|
||||
if not current_user.is_admin:
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
try:
|
||||
from models import PricingPlan
|
||||
|
||||
# Get all active pricing plans ordered by order_index
|
||||
plans = PricingPlan.query.filter_by(is_active=True).order_by(PricingPlan.order_index).all()
|
||||
|
||||
plans_data = []
|
||||
for plan in plans:
|
||||
plans_data.append({
|
||||
'id': plan.id,
|
||||
'name': plan.name,
|
||||
'description': plan.description,
|
||||
'monthly_price': plan.monthly_price,
|
||||
'annual_price': plan.annual_price,
|
||||
'features': plan.features,
|
||||
'button_text': plan.button_text,
|
||||
'button_url': plan.button_url,
|
||||
'is_popular': plan.is_popular,
|
||||
'is_custom': plan.is_custom,
|
||||
'is_active': plan.is_active,
|
||||
'order_index': plan.order_index,
|
||||
'room_quota': plan.room_quota,
|
||||
'conversation_quota': plan.conversation_quota,
|
||||
'storage_quota_gb': plan.storage_quota_gb,
|
||||
'manager_quota': plan.manager_quota,
|
||||
'admin_quota': plan.admin_quota,
|
||||
'format_quota_display': {
|
||||
'room_quota': plan.format_quota_display('room_quota'),
|
||||
'conversation_quota': plan.format_quota_display('conversation_quota'),
|
||||
'storage_quota_gb': plan.format_quota_display('storage_quota_gb'),
|
||||
'manager_quota': plan.format_quota_display('manager_quota'),
|
||||
'admin_quota': plan.format_quota_display('admin_quota')
|
||||
}
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'plans': plans_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -580,7 +580,8 @@ def get_version_info(current_user):
|
||||
'git_branch': os.environ.get('GIT_BRANCH', 'unknown'),
|
||||
'deployed_at': os.environ.get('DEPLOYED_AT', 'unknown'),
|
||||
'ismaster': os.environ.get('ISMASTER', 'false'),
|
||||
'port': os.environ.get('PORT', 'unknown')
|
||||
'port': os.environ.get('PORT', 'unknown'),
|
||||
'pricing_tier_name': os.environ.get('PRICING_TIER_NAME', 'unknown')
|
||||
}
|
||||
|
||||
return jsonify(version_info)
|
||||
@@ -591,6 +592,7 @@ def get_version_info(current_user):
|
||||
'app_version': 'unknown',
|
||||
'git_commit': 'unknown',
|
||||
'git_branch': 'unknown',
|
||||
'deployed_at': 'unknown'
|
||||
'deployed_at': 'unknown',
|
||||
'pricing_tier_name': 'unknown'
|
||||
}), 500
|
||||
|
||||
|
||||
@@ -1090,13 +1090,9 @@ def save_instance():
|
||||
|
||||
if existing_instance:
|
||||
# Update existing instance
|
||||
existing_instance.port = data['port']
|
||||
existing_instance.domains = data['domains']
|
||||
existing_instance.stack_id = data['stack_id']
|
||||
existing_instance.stack_name = data['stack_name']
|
||||
existing_instance.portainer_stack_id = data['stack_id']
|
||||
existing_instance.portainer_stack_name = data['stack_name']
|
||||
existing_instance.status = data['status']
|
||||
existing_instance.repository = data['repository']
|
||||
existing_instance.branch = data['branch']
|
||||
existing_instance.deployed_version = data.get('deployed_version', 'unknown')
|
||||
existing_instance.deployed_branch = data.get('deployed_branch', data['branch'])
|
||||
existing_instance.version_checked_at = datetime.utcnow()
|
||||
@@ -1107,13 +1103,9 @@ def save_instance():
|
||||
'message': 'Instance data updated successfully',
|
||||
'data': {
|
||||
'name': existing_instance.name,
|
||||
'port': existing_instance.port,
|
||||
'domains': existing_instance.domains,
|
||||
'stack_id': existing_instance.stack_id,
|
||||
'stack_name': existing_instance.stack_name,
|
||||
'portainer_stack_id': existing_instance.portainer_stack_id,
|
||||
'portainer_stack_name': existing_instance.portainer_stack_name,
|
||||
'status': existing_instance.status,
|
||||
'repository': existing_instance.repository,
|
||||
'branch': existing_instance.branch,
|
||||
'deployed_version': existing_instance.deployed_version,
|
||||
'deployed_branch': existing_instance.deployed_branch
|
||||
}
|
||||
@@ -1126,14 +1118,11 @@ def save_instance():
|
||||
rooms_count=0,
|
||||
conversations_count=0,
|
||||
data_size=0.0,
|
||||
payment_plan='Basic',
|
||||
payment_plan=data.get('payment_plan', 'Basic'),
|
||||
main_url=f"https://{data['domains'][0]}" if data['domains'] else f"http://localhost:{data['port']}",
|
||||
status=data['status'],
|
||||
port=data['port'],
|
||||
stack_id=data['stack_id'],
|
||||
stack_name=data['stack_name'],
|
||||
repository=data['repository'],
|
||||
branch=data['branch'],
|
||||
portainer_stack_id=data['stack_id'],
|
||||
portainer_stack_name=data['stack_name'],
|
||||
deployed_version=data.get('deployed_version', 'unknown'),
|
||||
deployed_branch=data.get('deployed_branch', data['branch'])
|
||||
)
|
||||
@@ -1145,13 +1134,9 @@ def save_instance():
|
||||
'message': 'Instance data saved successfully',
|
||||
'data': {
|
||||
'name': instance.name,
|
||||
'port': instance.port,
|
||||
'domains': instance.domains,
|
||||
'stack_id': instance.stack_id,
|
||||
'stack_name': instance.stack_name,
|
||||
'portainer_stack_id': instance.portainer_stack_id,
|
||||
'portainer_stack_name': instance.portainer_stack_name,
|
||||
'status': instance.status,
|
||||
'repository': instance.repository,
|
||||
'branch': instance.branch,
|
||||
'deployed_version': instance.deployed_version,
|
||||
'deployed_branch': instance.deployed_branch
|
||||
}
|
||||
|
||||
189
routes/main.py
189
routes/main.py
@@ -19,6 +19,7 @@ import smtplib
|
||||
import requests
|
||||
from functools import wraps
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Set up logging to show in console
|
||||
logging.basicConfig(
|
||||
@@ -491,13 +492,197 @@ def init_routes(main_bp):
|
||||
return jsonify({'error': 'Unauthorized'}), 403
|
||||
|
||||
instance = Instance.query.get_or_404(instance_id)
|
||||
|
||||
# Get Portainer settings
|
||||
portainer_settings = KeyValueSettings.get_value('portainer_settings')
|
||||
if not portainer_settings:
|
||||
current_app.logger.warning(f"Portainer settings not configured, proceeding with database deletion only for instance {instance.name}")
|
||||
# Continue with database deletion even if Portainer is not configured
|
||||
try:
|
||||
db.session.delete(instance)
|
||||
db.session.commit()
|
||||
current_app.logger.info(f"Successfully deleted instance from database: {instance.name}")
|
||||
return jsonify({'message': 'Instance deleted from database (Portainer not configured)'})
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"Error deleting instance {instance.name} from database: {str(e)}")
|
||||
return jsonify({'error': f'Failed to delete instance: {str(e)}'}), 500
|
||||
|
||||
try:
|
||||
# First, delete the Portainer stack and its volumes if stack information exists
|
||||
if instance.portainer_stack_id and instance.portainer_stack_name:
|
||||
current_app.logger.info(f"Deleting Portainer stack: {instance.portainer_stack_name} (ID: {instance.portainer_stack_id})")
|
||||
|
||||
# Get Portainer endpoint ID (assuming it's the first endpoint)
|
||||
try:
|
||||
endpoint_response = requests.get(
|
||||
f"{portainer_settings['url'].rstrip('/')}/api/endpoints",
|
||||
headers={
|
||||
'X-API-Key': portainer_settings['api_key'],
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if not endpoint_response.ok:
|
||||
current_app.logger.error(f"Failed to get Portainer endpoints: {endpoint_response.text}")
|
||||
return jsonify({'error': 'Failed to get Portainer endpoints'}), 500
|
||||
|
||||
endpoints = endpoint_response.json()
|
||||
if not endpoints:
|
||||
current_app.logger.error("No Portainer endpoints found")
|
||||
return jsonify({'error': 'No Portainer endpoints found'}), 400
|
||||
|
||||
endpoint_id = endpoints[0]['Id']
|
||||
|
||||
# Delete the stack (this will also remove associated volumes)
|
||||
delete_url = f"{portainer_settings['url'].rstrip('/')}/api/stacks/{instance.portainer_stack_id}"
|
||||
delete_response = requests.delete(
|
||||
delete_url,
|
||||
headers={
|
||||
'X-API-Key': portainer_settings['api_key'],
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
params={'endpointId': endpoint_id},
|
||||
timeout=60 # Give more time for stack deletion
|
||||
)
|
||||
|
||||
if delete_response.ok:
|
||||
current_app.logger.info(f"Successfully deleted Portainer stack: {instance.portainer_stack_name}")
|
||||
else:
|
||||
current_app.logger.warning(f"Failed to delete Portainer stack: {delete_response.status_code} - {delete_response.text}")
|
||||
# Continue with database deletion even if Portainer deletion fails
|
||||
|
||||
# Also try to delete any orphaned volumes associated with this stack
|
||||
try:
|
||||
volumes_url = f"{portainer_settings['url'].rstrip('/')}/api/endpoints/{endpoint_id}/docker/volumes"
|
||||
volumes_response = requests.get(
|
||||
volumes_url,
|
||||
headers={
|
||||
'X-API-Key': portainer_settings['api_key'],
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if volumes_response.ok:
|
||||
volumes = volumes_response.json().get('Volumes', [])
|
||||
stack_volumes = [vol for vol in volumes if vol.get('Labels', {}).get('com.docker.stack.namespace') == instance.portainer_stack_name]
|
||||
|
||||
for volume in stack_volumes:
|
||||
volume_name = volume.get('Name')
|
||||
if volume_name:
|
||||
delete_volume_url = f"{volumes_url}/{volume_name}"
|
||||
volume_delete_response = requests.delete(
|
||||
delete_volume_url,
|
||||
headers={
|
||||
'X-API-Key': portainer_settings['api_key'],
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if volume_delete_response.ok:
|
||||
current_app.logger.info(f"Successfully deleted volume: {volume_name}")
|
||||
else:
|
||||
current_app.logger.warning(f"Failed to delete volume {volume_name}: {volume_delete_response.status_code}")
|
||||
else:
|
||||
current_app.logger.warning(f"Failed to get volumes list: {volumes_response.status_code}")
|
||||
except Exception as volume_error:
|
||||
current_app.logger.warning(f"Error cleaning up volumes: {str(volume_error)}")
|
||||
|
||||
except requests.exceptions.RequestException as req_error:
|
||||
current_app.logger.error(f"Network error during Portainer operations: {str(req_error)}")
|
||||
# Continue with database deletion even if Portainer operations fail
|
||||
else:
|
||||
current_app.logger.info(f"No Portainer stack information found for instance {instance.name}, proceeding with database deletion only")
|
||||
|
||||
# Clean up NGINX proxy host if NGINX settings are configured
|
||||
nginx_settings = KeyValueSettings.get_value('nginx_settings')
|
||||
if nginx_settings and instance.main_url:
|
||||
current_app.logger.info(f"Cleaning up NGINX proxy host for instance {instance.name}")
|
||||
try:
|
||||
# Extract domain from main_url
|
||||
parsed_url = urlparse(instance.main_url)
|
||||
domain = parsed_url.netloc
|
||||
|
||||
if domain:
|
||||
# Get NGINX JWT token
|
||||
token_response = requests.post(
|
||||
f"{nginx_settings['url'].rstrip('/')}/api/tokens",
|
||||
json={
|
||||
'identity': nginx_settings['username'],
|
||||
'secret': nginx_settings['password']
|
||||
},
|
||||
headers={'Content-Type': 'application/json'},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if token_response.ok:
|
||||
token_data = token_response.json()
|
||||
token = token_data.get('token')
|
||||
|
||||
if token:
|
||||
# Get all proxy hosts to find the one matching this domain
|
||||
proxy_hosts_response = requests.get(
|
||||
f"{nginx_settings['url'].rstrip('/')}/api/nginx/proxy-hosts",
|
||||
headers={
|
||||
'Authorization': f'Bearer {token}',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if proxy_hosts_response.ok:
|
||||
proxy_hosts = proxy_hosts_response.json()
|
||||
|
||||
# Find proxy host with matching domain
|
||||
matching_proxy = None
|
||||
for proxy_host in proxy_hosts:
|
||||
if proxy_host.get('domain_names') and domain in proxy_host['domain_names']:
|
||||
matching_proxy = proxy_host
|
||||
break
|
||||
|
||||
if matching_proxy:
|
||||
# Delete the proxy host
|
||||
delete_response = requests.delete(
|
||||
f"{nginx_settings['url'].rstrip('/')}/api/nginx/proxy-hosts/{matching_proxy['id']}",
|
||||
headers={
|
||||
'Authorization': f'Bearer {token}',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if delete_response.ok:
|
||||
current_app.logger.info(f"Successfully deleted NGINX proxy host for domain: {domain}")
|
||||
else:
|
||||
current_app.logger.warning(f"Failed to delete NGINX proxy host: {delete_response.status_code} - {delete_response.text}")
|
||||
else:
|
||||
current_app.logger.info(f"No NGINX proxy host found for domain: {domain}")
|
||||
else:
|
||||
current_app.logger.warning(f"Failed to get NGINX proxy hosts: {proxy_hosts_response.status_code}")
|
||||
else:
|
||||
current_app.logger.warning("No NGINX token received")
|
||||
else:
|
||||
current_app.logger.warning(f"Failed to authenticate with NGINX: {token_response.status_code}")
|
||||
except Exception as nginx_error:
|
||||
current_app.logger.warning(f"Error cleaning up NGINX proxy host: {str(nginx_error)}")
|
||||
# Continue with database deletion even if NGINX cleanup fails
|
||||
else:
|
||||
current_app.logger.info(f"No NGINX settings configured or no main_url for instance {instance.name}, skipping NGINX cleanup")
|
||||
|
||||
# Now delete the instance from the database
|
||||
db.session.delete(instance)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Instance deleted successfully'})
|
||||
|
||||
current_app.logger.info(f"Successfully deleted instance: {instance.name}")
|
||||
return jsonify({'message': 'Instance and all associated resources (Portainer stack, volumes, NGINX proxy host) deleted successfully'})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
current_app.logger.error(f"Error deleting instance {instance.name}: {str(e)}")
|
||||
return jsonify({'error': f'Failed to delete instance: {str(e)}'}), 500
|
||||
|
||||
@main_bp.route('/instances/<int:instance_id>/status')
|
||||
@login_required
|
||||
|
||||
@@ -57,7 +57,12 @@ def init_public_routes(public_bp):
|
||||
articles = HelpArticle.get_articles_by_category(category)
|
||||
category_name = categories[category]
|
||||
else:
|
||||
# Show all articles when no specific category is requested
|
||||
articles = []
|
||||
for category_articles in all_articles.values():
|
||||
articles.extend(category_articles)
|
||||
# Sort by order_index and then by created_at
|
||||
articles.sort(key=lambda x: (x.order_index, x.created_at))
|
||||
category_name = None
|
||||
|
||||
return render_template('public/help_articles.html',
|
||||
|
||||
252
static/css/instances.css
Normal file
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
1777
static/js/instances.js
Normal file
1777
static/js/instances.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -499,7 +499,8 @@ async function startLaunch(data) {
|
||||
`;
|
||||
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({
|
||||
step: 'Stack Deployment',
|
||||
status: stackResult.success ? 'success' : 'error',
|
||||
@@ -530,7 +531,7 @@ async function startLaunch(data) {
|
||||
|
||||
// Continue with the process using the available data
|
||||
stackResult.data = stackResult.data || {
|
||||
name: `docupulse_${data.port}`,
|
||||
name: stackName,
|
||||
status: 'creating',
|
||||
id: null
|
||||
};
|
||||
@@ -593,6 +594,9 @@ async function startLaunch(data) {
|
||||
// Save instance data
|
||||
await updateStep(10, 'Saving Instance Data', 'Storing instance information...');
|
||||
try {
|
||||
// Get the launch data from sessionStorage to access pricing tier info
|
||||
const launchData = JSON.parse(sessionStorage.getItem('instanceLaunchData') || '{}');
|
||||
|
||||
const instanceData = {
|
||||
name: data.instanceName,
|
||||
port: data.port,
|
||||
@@ -603,7 +607,8 @@ async function startLaunch(data) {
|
||||
repository: data.repository,
|
||||
branch: data.branch,
|
||||
deployed_version: dockerComposeResult.latest_tag || dockerComposeResult.commit_hash || 'unknown',
|
||||
deployed_branch: data.branch
|
||||
deployed_branch: data.branch,
|
||||
payment_plan: launchData.pricingTier?.name || 'Basic' // Use the selected pricing tier name
|
||||
};
|
||||
console.log('Saving instance data:', instanceData);
|
||||
const saveResult = await saveInstanceData(instanceData);
|
||||
@@ -2046,28 +2051,45 @@ async function saveInstanceData(instanceData) {
|
||||
|
||||
if (existingInstance) {
|
||||
console.log('Instance already exists:', instanceData.port);
|
||||
// Update existing instance with new data
|
||||
const updateResponse = await fetch('/api/admin/save-instance', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: instanceData.port,
|
||||
port: instanceData.port,
|
||||
domains: instanceData.domains,
|
||||
stack_id: instanceData.stack_id || '',
|
||||
stack_name: instanceData.stack_name,
|
||||
status: instanceData.status,
|
||||
repository: instanceData.repository,
|
||||
branch: instanceData.branch,
|
||||
deployed_version: instanceData.deployed_version,
|
||||
deployed_branch: instanceData.deployed_branch,
|
||||
payment_plan: instanceData.payment_plan || 'Basic'
|
||||
})
|
||||
});
|
||||
|
||||
if (!updateResponse.ok) {
|
||||
const errorText = await updateResponse.text();
|
||||
console.error('Error updating instance:', errorText);
|
||||
throw new Error(`Failed to update instance data: ${updateResponse.status} ${updateResponse.statusText}`);
|
||||
}
|
||||
|
||||
const updateResult = await updateResponse.json();
|
||||
console.log('Instance updated:', updateResult);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
name: instanceData.port,
|
||||
company: 'loading...',
|
||||
rooms_count: 0,
|
||||
conversations_count: 0,
|
||||
data_size: 0.0,
|
||||
payment_plan: 'Basic',
|
||||
main_url: `https://${instanceData.domains[0]}`,
|
||||
status: 'inactive',
|
||||
port: instanceData.port,
|
||||
stack_id: instanceData.stack_id || '', // Use empty string if null
|
||||
stack_name: instanceData.stack_name,
|
||||
repository: instanceData.repository,
|
||||
branch: instanceData.branch
|
||||
}
|
||||
data: updateResult.data
|
||||
};
|
||||
}
|
||||
|
||||
// If instance doesn't exist, create it
|
||||
const response = await fetch('/instances/add', {
|
||||
const response = await fetch('/api/admin/save-instance', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -2075,18 +2097,16 @@ async function saveInstanceData(instanceData) {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: instanceData.port,
|
||||
company: 'loading...',
|
||||
rooms_count: 0,
|
||||
conversations_count: 0,
|
||||
data_size: 0.0,
|
||||
payment_plan: 'Basic',
|
||||
main_url: `https://${instanceData.domains[0]}`,
|
||||
status: 'inactive',
|
||||
port: instanceData.port,
|
||||
stack_id: instanceData.stack_id || '', // Use empty string if null
|
||||
domains: instanceData.domains,
|
||||
stack_id: instanceData.stack_id || '',
|
||||
stack_name: instanceData.stack_name,
|
||||
status: instanceData.status,
|
||||
repository: instanceData.repository,
|
||||
branch: instanceData.branch
|
||||
branch: instanceData.branch,
|
||||
deployed_version: instanceData.deployed_version,
|
||||
deployed_branch: instanceData.deployed_branch,
|
||||
payment_plan: instanceData.payment_plan || 'Basic'
|
||||
})
|
||||
});
|
||||
|
||||
@@ -2599,6 +2619,32 @@ async function checkStackExists(stackName) {
|
||||
// Add new function to deploy stack
|
||||
async function deployStack(dockerComposeContent, stackName, port) {
|
||||
try {
|
||||
// Get the launch data from sessionStorage to access pricing tier info
|
||||
const launchData = JSON.parse(sessionStorage.getItem('instanceLaunchData'));
|
||||
|
||||
// Fetch the pricing tier details to get the actual quota values
|
||||
let pricingTierDetails = null;
|
||||
if (launchData?.pricingTier?.id) {
|
||||
try {
|
||||
const pricingResponse = await fetch(`/api/admin/pricing-plans/${launchData.pricingTier.id}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
||||
}
|
||||
});
|
||||
|
||||
if (pricingResponse.ok) {
|
||||
const pricingData = await pricingResponse.json();
|
||||
if (pricingData.success) {
|
||||
pricingTierDetails = pricingData.plan;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch pricing tier details:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// First, attempt to deploy the stack
|
||||
const response = await fetch('/api/admin/deploy-stack', {
|
||||
method: 'POST',
|
||||
@@ -2607,7 +2653,7 @@ async function deployStack(dockerComposeContent, stackName, port) {
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: `docupulse_${port}`,
|
||||
name: stackName,
|
||||
StackFileContent: dockerComposeContent,
|
||||
Env: [
|
||||
{
|
||||
@@ -2633,6 +2679,35 @@ async function deployStack(dockerComposeContent, stackName, port) {
|
||||
{
|
||||
name: 'DEPLOYED_AT',
|
||||
value: new Date().toISOString()
|
||||
},
|
||||
// Pricing tier environment variables with actual quota values
|
||||
{
|
||||
name: 'PRICING_TIER_ID',
|
||||
value: launchData?.pricingTier?.id?.toString() || '0'
|
||||
},
|
||||
{
|
||||
name: 'PRICING_TIER_NAME',
|
||||
value: launchData?.pricingTier?.name || 'Unknown'
|
||||
},
|
||||
{
|
||||
name: 'ROOM_QUOTA',
|
||||
value: pricingTierDetails?.room_quota?.toString() || '0'
|
||||
},
|
||||
{
|
||||
name: 'CONVERSATION_QUOTA',
|
||||
value: pricingTierDetails?.conversation_quota?.toString() || '0'
|
||||
},
|
||||
{
|
||||
name: 'STORAGE_QUOTA_GB',
|
||||
value: pricingTierDetails?.storage_quota_gb?.toString() || '0'
|
||||
},
|
||||
{
|
||||
name: 'MANAGER_QUOTA',
|
||||
value: pricingTierDetails?.manager_quota?.toString() || '0'
|
||||
},
|
||||
{
|
||||
name: 'ADMIN_QUOTA',
|
||||
value: pricingTierDetails?.admin_quota?.toString() || '0'
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -2653,7 +2728,7 @@ async function deployStack(dockerComposeContent, stackName, port) {
|
||||
|
||||
// Start polling immediately since the stack creation was initiated
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -2668,7 +2743,7 @@ async function deployStack(dockerComposeContent, stackName, port) {
|
||||
// If stack is being created, poll for status
|
||||
if (result.data.status === 'creating') {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -2843,4 +2918,16 @@ async function pollStackStatus(stackName, maxWaitTime = 15 * 60 * 1000) {
|
||||
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 head %}
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-bs4.min.css" rel="stylesheet">
|
||||
<style>
|
||||
@@ -227,6 +231,7 @@
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete this article? This action cannot be undone.</p>
|
||||
<p class="text-muted" id="deleteArticleTitle"></p>
|
||||
<input type="hidden" id="deleteArticleId" value="">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
@@ -295,7 +300,15 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
function loadArticles() {
|
||||
fetch('/api/admin/help-articles')
|
||||
.then(response => response.json())
|
||||
.then(response => {
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (!contentType || !contentType.includes('application/json')) {
|
||||
return response.text().then(text => {
|
||||
throw new Error(`Server returned non-JSON response: ${text.substring(0, 200)}...`);
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
const articlesList = document.getElementById('articlesList');
|
||||
articlesList.innerHTML = '';
|
||||
@@ -377,7 +390,17 @@ function createArticle() {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(response => {
|
||||
// Check if response is JSON
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (!contentType || !contentType.includes('application/json')) {
|
||||
// If not JSON, get the text and throw an error
|
||||
return response.text().then(text => {
|
||||
throw new Error(`Server returned non-JSON response: ${text.substring(0, 200)}...`);
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('createArticleModal'));
|
||||
@@ -398,7 +421,15 @@ function createArticle() {
|
||||
|
||||
function editArticle(articleId) {
|
||||
fetch(`/api/admin/help-articles/${articleId}`)
|
||||
.then(response => response.json())
|
||||
.then(response => {
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (!contentType || !contentType.includes('application/json')) {
|
||||
return response.text().then(text => {
|
||||
throw new Error(`Server returned non-JSON response: ${text.substring(0, 200)}...`);
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
document.getElementById('editArticleId').value = data.article.id;
|
||||
document.getElementById('editArticleTitle').value = data.article.title;
|
||||
@@ -426,7 +457,15 @@ function updateArticle() {
|
||||
method: 'PUT',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(response => {
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (!contentType || !contentType.includes('application/json')) {
|
||||
return response.text().then(text => {
|
||||
throw new Error(`Server returned non-JSON response: ${text.substring(0, 200)}...`);
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('editArticleModal'));
|
||||
@@ -457,7 +496,15 @@ function deleteArticle() {
|
||||
fetch(`/api/admin/help-articles/${articleId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(response => {
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (!contentType || !contentType.includes('application/json')) {
|
||||
return response.text().then(text => {
|
||||
throw new Error(`Server returned non-JSON response: ${text.substring(0, 200)}...`);
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('deleteArticleModal'));
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/home.css') }}?v={{ 'css/home.css'|asset_version }}">
|
||||
<style>
|
||||
.admin-link {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||
<style>
|
||||
.about-section {
|
||||
padding: 80px 0;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||
<style>
|
||||
.careers-section {
|
||||
padding: 80px 0;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||
<style>
|
||||
.legal-section {
|
||||
padding: 80px 0;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||
<style>
|
||||
.contact-section {
|
||||
padding: 80px 0;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||
<style>
|
||||
/* Enhanced Features Page Styles */
|
||||
.hero-section {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||
<style>
|
||||
.legal-section {
|
||||
padding: 80px 0;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||
<style>
|
||||
.help-section {
|
||||
padding: 80px 0;
|
||||
@@ -415,23 +416,5 @@
|
||||
{% include 'components/footer_nav.html' %}
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
// Search functionality
|
||||
document.querySelector('.search-box').addEventListener('input', function(e) {
|
||||
const searchTerm = e.target.value.toLowerCase();
|
||||
const faqItems = document.querySelectorAll('.faq-item');
|
||||
|
||||
faqItems.forEach(item => {
|
||||
const question = item.querySelector('.faq-question').textContent.toLowerCase();
|
||||
const answer = item.querySelector('.faq-answer').textContent.toLowerCase();
|
||||
|
||||
if (question.includes(searchTerm) || answer.includes(searchTerm)) {
|
||||
item.style.display = 'block';
|
||||
} else {
|
||||
item.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -8,6 +8,7 @@
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||
<style>
|
||||
.category-nav {
|
||||
background: var(--white);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||
<style>
|
||||
.press-section {
|
||||
padding: 80px 0;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||
<style>
|
||||
.pricing-section {
|
||||
padding: 80px 0;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||
<style>
|
||||
.legal-section {
|
||||
padding: 80px 0;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||
<style>
|
||||
.security-section {
|
||||
padding: 80px 0;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/colors.css') }}?v={{ 'css/colors.css'|asset_version }}">
|
||||
<link rel="stylesheet" href="{{ url_for('main.dynamic_colors') }}?v={{ site_settings.updated_at.timestamp() }}" onload="console.log('[CSS] Dynamic colors loaded with version:', '{{ site_settings.updated_at.timestamp() }}')">
|
||||
<style>
|
||||
.legal-section {
|
||||
padding: 80px 0;
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user