Details for instances
This commit is contained in:
Binary file not shown.
310
routes/main.py
310
routes/main.py
@@ -503,6 +503,93 @@ def init_routes(main_bp):
|
|||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
return jsonify({'error': str(e)}), 400
|
return jsonify({'error': str(e)}), 400
|
||||||
|
|
||||||
|
@main_bp.route('/instances/<int:instance_id>/detail')
|
||||||
|
@login_required
|
||||||
|
@require_password_change
|
||||||
|
def instance_detail(instance_id):
|
||||||
|
if not os.environ.get('MASTER', 'false').lower() == 'true':
|
||||||
|
flash('This page is only available in master instances.', 'error')
|
||||||
|
return redirect(url_for('main.dashboard'))
|
||||||
|
|
||||||
|
instance = Instance.query.get_or_404(instance_id)
|
||||||
|
|
||||||
|
# Check instance status
|
||||||
|
status_info = check_instance_status(instance)
|
||||||
|
instance.status = status_info['status']
|
||||||
|
instance.status_details = status_info['details']
|
||||||
|
|
||||||
|
# Fetch company name from instance settings
|
||||||
|
try:
|
||||||
|
if instance.connection_token:
|
||||||
|
# First get JWT token
|
||||||
|
jwt_response = requests.post(
|
||||||
|
f"{instance.main_url.rstrip('/')}/api/admin/management-token",
|
||||||
|
headers={
|
||||||
|
'X-API-Key': instance.connection_token,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
if jwt_response.status_code == 200:
|
||||||
|
jwt_data = jwt_response.json()
|
||||||
|
jwt_token = jwt_data.get('token')
|
||||||
|
|
||||||
|
if jwt_token:
|
||||||
|
# Then fetch settings with JWT token
|
||||||
|
response = requests.get(
|
||||||
|
f"{instance.main_url.rstrip('/')}/api/admin/settings",
|
||||||
|
headers={
|
||||||
|
'Authorization': f'Bearer {jwt_token}',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
if 'company_name' in data:
|
||||||
|
instance.company = data['company_name']
|
||||||
|
db.session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"Error fetching instance settings: {str(e)}")
|
||||||
|
|
||||||
|
return render_template('main/instance_detail.html', instance=instance)
|
||||||
|
|
||||||
|
@main_bp.route('/instances/<int:instance_id>/auth-status')
|
||||||
|
@login_required
|
||||||
|
@require_password_change
|
||||||
|
def instance_auth_status(instance_id):
|
||||||
|
if not os.environ.get('MASTER', 'false').lower() == 'true':
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 403
|
||||||
|
|
||||||
|
instance = Instance.query.get_or_404(instance_id)
|
||||||
|
|
||||||
|
# Check if instance has a connection token
|
||||||
|
has_token = bool(instance.connection_token)
|
||||||
|
|
||||||
|
# If there's a token, verify it's still valid
|
||||||
|
is_valid = False
|
||||||
|
if has_token:
|
||||||
|
try:
|
||||||
|
# Try to get a JWT token using the connection token
|
||||||
|
response = requests.post(
|
||||||
|
f"{instance.main_url.rstrip('/')}/api/admin/management-token",
|
||||||
|
headers={
|
||||||
|
'X-API-Key': instance.connection_token,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
is_valid = response.status_code == 200
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"Error verifying token: {str(e)}")
|
||||||
|
is_valid = False
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'authenticated': has_token and is_valid,
|
||||||
|
'has_token': has_token,
|
||||||
|
'is_valid': is_valid
|
||||||
|
})
|
||||||
|
|
||||||
UPLOAD_FOLDER = '/app/uploads/profile_pics'
|
UPLOAD_FOLDER = '/app/uploads/profile_pics'
|
||||||
if not os.path.exists(UPLOAD_FOLDER):
|
if not os.path.exists(UPLOAD_FOLDER):
|
||||||
os.makedirs(UPLOAD_FOLDER)
|
os.makedirs(UPLOAD_FOLDER)
|
||||||
@@ -1402,226 +1489,3 @@ def init_routes(main_bp):
|
|||||||
'Content-Disposition': f'attachment; filename=event_log_{datetime.utcnow().strftime("%Y%m%d_%H%M%S")}.csv'
|
'Content-Disposition': f'attachment; filename=event_log_{datetime.utcnow().strftime("%Y%m%d_%H%M%S")}.csv'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@main_bp.route('/settings/email-templates/<int:template_id>', methods=['PUT'])
|
|
||||||
@login_required
|
|
||||||
def update_email_template(template_id):
|
|
||||||
if not current_user.is_admin:
|
|
||||||
return jsonify({'error': 'Unauthorized'}), 403
|
|
||||||
|
|
||||||
template = EmailTemplate.query.get_or_404(template_id)
|
|
||||||
|
|
||||||
data = request.get_json()
|
|
||||||
if not data:
|
|
||||||
return jsonify({'error': 'No data provided'}), 400
|
|
||||||
|
|
||||||
template.subject = data.get('subject', template.subject)
|
|
||||||
template.body = data.get('body', template.body)
|
|
||||||
|
|
||||||
try:
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Log the template update
|
|
||||||
log_event(
|
|
||||||
event_type='settings_update',
|
|
||||||
details={
|
|
||||||
'user_id': current_user.id,
|
|
||||||
'user_name': f"{current_user.username} {current_user.last_name}",
|
|
||||||
'update_type': 'email_template',
|
|
||||||
'template_id': template.id,
|
|
||||||
'template_name': template.name,
|
|
||||||
'changes': {
|
|
||||||
'subject': template.subject,
|
|
||||||
'body': template.body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'message': 'Template updated successfully',
|
|
||||||
'template': {
|
|
||||||
'id': template.id,
|
|
||||||
'name': template.name,
|
|
||||||
'subject': template.subject,
|
|
||||||
'body': template.body
|
|
||||||
}
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
return jsonify({'error': str(e)}), 500
|
|
||||||
|
|
||||||
@main_bp.route('/settings/mails')
|
|
||||||
@login_required
|
|
||||||
def mails():
|
|
||||||
if not current_user.is_admin:
|
|
||||||
flash('You do not have permission to access settings.', 'error')
|
|
||||||
return redirect(url_for('main.index'))
|
|
||||||
|
|
||||||
# Get filter parameters
|
|
||||||
status = request.args.get('status', '')
|
|
||||||
date_range = request.args.get('date_range', '7d')
|
|
||||||
user_id = request.args.get('user_id', '')
|
|
||||||
template_id = request.args.get('template_id', '')
|
|
||||||
page = request.args.get('page', 1, type=int)
|
|
||||||
per_page = 10
|
|
||||||
|
|
||||||
# Build query
|
|
||||||
query = Mail.query
|
|
||||||
|
|
||||||
# Apply filters
|
|
||||||
if status:
|
|
||||||
query = query.filter_by(status=status)
|
|
||||||
if user_id:
|
|
||||||
query = query.filter_by(recipient=user_id)
|
|
||||||
if template_id:
|
|
||||||
query = query.filter_by(template_id=template_id)
|
|
||||||
if date_range:
|
|
||||||
if date_range == '24h':
|
|
||||||
cutoff = datetime.utcnow() - timedelta(hours=24)
|
|
||||||
elif date_range == '7d':
|
|
||||||
cutoff = datetime.utcnow() - timedelta(days=7)
|
|
||||||
elif date_range == '30d':
|
|
||||||
cutoff = datetime.utcnow() - timedelta(days=30)
|
|
||||||
else:
|
|
||||||
cutoff = None
|
|
||||||
if cutoff:
|
|
||||||
query = query.filter(Mail.created_at >= cutoff)
|
|
||||||
|
|
||||||
# Get paginated results
|
|
||||||
mails = query.order_by(Mail.created_at.desc()).paginate(page=page, per_page=per_page)
|
|
||||||
total_pages = mails.pages
|
|
||||||
current_page = mails.page
|
|
||||||
|
|
||||||
# Get all users for the filter dropdown
|
|
||||||
users = User.query.order_by(User.username).all()
|
|
||||||
|
|
||||||
# Get all email templates
|
|
||||||
email_templates = EmailTemplate.query.filter_by(is_active=True).all()
|
|
||||||
|
|
||||||
# Check if this is an AJAX request
|
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
|
||||||
return render_template('settings/tabs/mails.html',
|
|
||||||
mails=mails,
|
|
||||||
total_pages=total_pages,
|
|
||||||
current_page=page,
|
|
||||||
status=status,
|
|
||||||
date_range=date_range,
|
|
||||||
user_id=user_id,
|
|
||||||
template_id=template_id,
|
|
||||||
users=users,
|
|
||||||
email_templates=email_templates,
|
|
||||||
csrf_token=generate_csrf())
|
|
||||||
|
|
||||||
# For full page requests, render the full settings page
|
|
||||||
site_settings = SiteSettings.get_settings()
|
|
||||||
company_form = CompanySettingsForm()
|
|
||||||
|
|
||||||
return render_template('settings/settings.html',
|
|
||||||
primary_color=site_settings.primary_color,
|
|
||||||
secondary_color=site_settings.secondary_color,
|
|
||||||
active_tab='mails',
|
|
||||||
site_settings=site_settings,
|
|
||||||
mails=mails,
|
|
||||||
total_pages=total_pages,
|
|
||||||
current_page=page,
|
|
||||||
status=status,
|
|
||||||
date_range=date_range,
|
|
||||||
user_id=user_id,
|
|
||||||
template_id=template_id,
|
|
||||||
users=users,
|
|
||||||
email_templates=email_templates,
|
|
||||||
form=company_form,
|
|
||||||
csrf_token=generate_csrf())
|
|
||||||
|
|
||||||
@main_bp.route('/settings/mails/<int:mail_id>')
|
|
||||||
@login_required
|
|
||||||
def get_mail_details(mail_id):
|
|
||||||
if not current_user.is_admin:
|
|
||||||
return jsonify({'error': 'Unauthorized'}), 403
|
|
||||||
|
|
||||||
mail = Mail.query.get_or_404(mail_id)
|
|
||||||
return jsonify({
|
|
||||||
'id': mail.id,
|
|
||||||
'recipient': mail.recipient,
|
|
||||||
'subject': mail.subject,
|
|
||||||
'body': mail.body,
|
|
||||||
'status': mail.status,
|
|
||||||
'created_at': mail.created_at.isoformat(),
|
|
||||||
'sent_at': mail.sent_at.isoformat() if mail.sent_at else None,
|
|
||||||
'template': {
|
|
||||||
'id': mail.template.id,
|
|
||||||
'name': mail.template.name
|
|
||||||
} if mail.template else None
|
|
||||||
})
|
|
||||||
|
|
||||||
@main_bp.route('/settings/mails/download')
|
|
||||||
@login_required
|
|
||||||
def download_mails():
|
|
||||||
if not current_user.is_admin:
|
|
||||||
flash('Only administrators can download mail logs.', 'error')
|
|
||||||
return redirect(url_for('main.dashboard'))
|
|
||||||
|
|
||||||
# Get filter parameters
|
|
||||||
status = request.args.get('status')
|
|
||||||
date_range = request.args.get('date_range', '7d')
|
|
||||||
user_id = request.args.get('user_id')
|
|
||||||
|
|
||||||
# Calculate date range
|
|
||||||
end_date = datetime.utcnow()
|
|
||||||
if date_range == '24h':
|
|
||||||
start_date = end_date - timedelta(days=1)
|
|
||||||
elif date_range == '7d':
|
|
||||||
start_date = end_date - timedelta(days=7)
|
|
||||||
elif date_range == '30d':
|
|
||||||
start_date = end_date - timedelta(days=30)
|
|
||||||
else:
|
|
||||||
start_date = None
|
|
||||||
|
|
||||||
# Build query
|
|
||||||
query = Mail.query
|
|
||||||
|
|
||||||
if status:
|
|
||||||
query = query.filter_by(status=status)
|
|
||||||
if start_date:
|
|
||||||
query = query.filter(Mail.created_at >= start_date)
|
|
||||||
if user_id:
|
|
||||||
query = query.filter(Mail.recipient == User.query.get(user_id).email)
|
|
||||||
|
|
||||||
# Get all mails
|
|
||||||
mails = query.order_by(Mail.created_at.desc()).all()
|
|
||||||
|
|
||||||
# Create CSV
|
|
||||||
output = StringIO()
|
|
||||||
writer = csv.writer(output)
|
|
||||||
|
|
||||||
# Write header
|
|
||||||
writer.writerow([
|
|
||||||
'Created At',
|
|
||||||
'Recipient',
|
|
||||||
'Subject',
|
|
||||||
'Status',
|
|
||||||
'Template',
|
|
||||||
'Sent At'
|
|
||||||
])
|
|
||||||
|
|
||||||
# Write data
|
|
||||||
for mail in mails:
|
|
||||||
writer.writerow([
|
|
||||||
mail.created_at.strftime('%Y-%m-%d %H:%M:%S'),
|
|
||||||
mail.recipient,
|
|
||||||
mail.subject,
|
|
||||||
mail.status,
|
|
||||||
mail.template.name if mail.template else '-',
|
|
||||||
mail.sent_at.strftime('%Y-%m-%d %H:%M:%S') if mail.sent_at else '-'
|
|
||||||
])
|
|
||||||
|
|
||||||
output.seek(0)
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
output,
|
|
||||||
mimetype='text/csv',
|
|
||||||
headers={
|
|
||||||
'Content-Disposition': f'attachment; filename=mail_log_{datetime.utcnow().strftime("%Y%m%d_%H%M%S")}.csv'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
173
templates/main/instance_detail.html
Normal file
173
templates/main/instance_detail.html
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
{% extends "common/base.html" %}
|
||||||
|
{% from "components/header.html" import header %}
|
||||||
|
|
||||||
|
{% block title %}{{ instance.name }} - DocuPulse{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{{ header(
|
||||||
|
title="Instance Details",
|
||||||
|
description=instance.name + " for " + instance.company,
|
||||||
|
icon="fa-server",
|
||||||
|
buttons=[
|
||||||
|
{
|
||||||
|
'text': 'Back to Instances',
|
||||||
|
'url': '/instances',
|
||||||
|
'icon': 'fa-arrow-left',
|
||||||
|
'class': 'btn-secondary'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
) }}
|
||||||
|
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<!-- Activity Status Card -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="status-icon-wrapper">
|
||||||
|
<i class="fas fa-circle-notch fa-spin {% if instance.status == 'active' %}text-success{% else %}text-danger{% endif %} fa-2x"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<h5 class="card-title mb-1">Activity Status</h5>
|
||||||
|
<p class="card-text mb-0">
|
||||||
|
<span class="badge bg-{{ 'success' if instance.status == 'active' else 'danger' }}">
|
||||||
|
{{ instance.status|title }}
|
||||||
|
</span>
|
||||||
|
{% if instance.status_details %}
|
||||||
|
<small class="text-muted d-block mt-1">{{ instance.status_details }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Authentication Status Card -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="status-icon-wrapper">
|
||||||
|
<i class="fas fa-shield-alt {% if instance.connection_token %}text-success{% else %}text-warning{% endif %} fa-2x"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 ms-3">
|
||||||
|
<h5 class="card-title mb-1">Authentication Status</h5>
|
||||||
|
<p class="card-text mb-0">
|
||||||
|
<span class="badge bg-{{ 'success' if instance.connection_token else 'warning' }}">
|
||||||
|
{{ 'Authenticated' if instance.connection_token else 'Not Authenticated' }}
|
||||||
|
</span>
|
||||||
|
{% if not instance.connection_token %}
|
||||||
|
<small class="text-muted d-block mt-1">This instance needs to be authenticated</small>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content will be added later -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<!-- Content will be added later -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
let statusUpdateInterval;
|
||||||
|
|
||||||
|
// Function to check instance status
|
||||||
|
async function checkInstanceStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/instances/{{ instance.id }}/status`);
|
||||||
|
if (!response.ok) throw new Error('Failed to check instance status');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Update activity status
|
||||||
|
const activityIcon = document.querySelector('.status-icon-wrapper .fa-circle-notch');
|
||||||
|
const activityBadge = document.querySelector('.card:first-child .badge');
|
||||||
|
const activityDetails = document.querySelector('.card:first-child .text-muted');
|
||||||
|
|
||||||
|
if (activityIcon) {
|
||||||
|
activityIcon.className = `fas fa-circle-notch fa-spin ${data.status === 'active' ? 'text-success' : 'text-danger'} fa-2x`;
|
||||||
|
}
|
||||||
|
if (activityBadge) {
|
||||||
|
activityBadge.className = `badge bg-${data.status === 'active' ? 'success' : 'danger'}`;
|
||||||
|
activityBadge.textContent = data.status.charAt(0).toUpperCase() + data.status.slice(1);
|
||||||
|
}
|
||||||
|
if (activityDetails && data.status_details) {
|
||||||
|
activityDetails.textContent = data.status_details;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking instance status:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to check authentication status
|
||||||
|
async function checkAuthStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/instances/{{ instance.id }}/auth-status`);
|
||||||
|
if (!response.ok) throw new Error('Failed to check authentication status');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Update authentication status
|
||||||
|
const authIcon = document.querySelector('.card:last-child .fa-shield-alt');
|
||||||
|
const authBadge = document.querySelector('.card:last-child .badge');
|
||||||
|
const authDetails = document.querySelector('.card:last-child .text-muted');
|
||||||
|
|
||||||
|
if (authIcon) {
|
||||||
|
authIcon.className = `fas fa-shield-alt ${data.authenticated ? 'text-success' : 'text-warning'} fa-2x`;
|
||||||
|
}
|
||||||
|
if (authBadge) {
|
||||||
|
authBadge.className = `badge bg-${data.authenticated ? 'success' : 'warning'}`;
|
||||||
|
authBadge.textContent = data.authenticated ? 'Authenticated' : 'Not Authenticated';
|
||||||
|
}
|
||||||
|
if (authDetails) {
|
||||||
|
authDetails.textContent = data.authenticated ? '' : 'This instance needs to be authenticated';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking authentication status:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to update all statuses
|
||||||
|
async function updateAllStatuses() {
|
||||||
|
await Promise.all([
|
||||||
|
checkInstanceStatus(),
|
||||||
|
checkAuthStatus()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize status updates
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Initial update
|
||||||
|
updateAllStatuses();
|
||||||
|
|
||||||
|
// Set up periodic updates (every 30 seconds)
|
||||||
|
statusUpdateInterval = setInterval(updateAllStatuses, 30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up interval when page is unloaded
|
||||||
|
window.addEventListener('beforeunload', function() {
|
||||||
|
if (statusUpdateInterval) {
|
||||||
|
clearInterval(statusUpdateInterval);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -92,6 +92,9 @@
|
|||||||
<button class="btn btn-sm btn-outline-primary" onclick="editInstance({{ instance.id }})">
|
<button class="btn btn-sm btn-outline-primary" onclick="editInstance({{ instance.id }})">
|
||||||
<i class="fas fa-edit"></i>
|
<i class="fas fa-edit"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-info" onclick="window.location.href='/instances/{{ instance.id }}/detail'">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteInstance({{ instance.id }})">
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteInstance({{ instance.id }})">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user