Files
JQC_multi_tenant/control/panel/templates/panel/tenant_detail.html
T
2026-06-28 19:00:34 -04:00

419 lines
18 KiB
HTML

{% extends "panel/base.html" %}
{% block title %}{{ tenant.slug }} — JQC Control{% endblock %}
{% block page_title %}Tenant: {{ tenant.name }} ({{ tenant.slug }}){% endblock %}
{% block content %}
{# ── breadcrumb ── #}
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb" style="font-size:.85rem;">
<li class="breadcrumb-item"><a href="{{ url_for('tenants.list_tenants') }}">Tenants</a></li>
<li class="breadcrumb-item active">{{ tenant.slug }}</li>
</ol>
</nav>
<div class="row g-3">
{# ═══ LEFT COLUMN ═══ #}
<div class="col-lg-8">
{# ── Info card ── #}
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white d-flex justify-content-between align-items-center py-2">
<span class="fw-semibold" style="font-size:.9rem;"><i class="bi bi-building me-1"></i>Tenant Info</span>
<span class="status-badge status-{{ tenant.status }}">{{ tenant.status }}</span>
</div>
<div class="card-body py-2">
<div class="row g-2" style="font-size:.85rem;">
<div class="col-6"><span class="text-muted">ID</span><div class="fw-medium">{{ tenant.id }}</div></div>
<div class="col-6"><span class="text-muted">Slug</span><div class="fw-medium">{{ tenant.slug }}</div></div>
<div class="col-6"><span class="text-muted">Name</span><div class="fw-medium">{{ tenant.name }}</div></div>
<div class="col-6"><span class="text-muted">Created</span>
<div class="fw-medium">{{ tenant.created_at.strftime('%Y-%m-%d %H:%M') if tenant.created_at else '—' }}</div></div>
{% if tenant.suspended_at %}
<div class="col-6"><span class="text-muted">Suspended</span>
<div class="text-warning fw-medium">{{ tenant.suspended_at.strftime('%Y-%m-%d %H:%M') }}</div></div>
{% endif %}
<div class="col-12"><span class="text-muted">DB</span>
<div class="fw-medium font-monospace" style="font-size:.78rem;">
{{ tenant.db_user }}@{{ tenant.db_host }}:{{ tenant.db_port }}/{{ tenant.db_name }}
</div>
</div>
</div>
</div>
</div>
{# ── Migration card ── #}
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white d-flex justify-content-between align-items-center py-2">
<span class="fw-semibold" style="font-size:.9rem;"><i class="bi bi-database-up me-1"></i>Schema Migration</span>
<form method="POST"
action="{{ url_for('tenants.migrate_tenant', tenant_id=tenant.id) }}"
onsubmit="return confirm('Run upgrade_tenant for {{ tenant.slug }}?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-outline-primary btn-sm">
<i class="bi bi-arrow-up-circle me-1"></i> Upgrade to Head
</button>
</form>
</div>
<div class="card-body py-2" style="font-size:.85rem;">
<div class="row g-1">
<div class="col-12">
<span class="text-muted">Chain head:</span>
<code class="ms-1">{{ chain_head }}</code>
</div>
<div class="col-12">
<span class="text-muted">Cached head:</span>
<code class="ms-1">{{ tenant.alembic_head or '—' }}</code>
</div>
<div class="col-12">
<span class="text-muted">Live DB rev:</span>
{% if live_rev == chain_head %}
<span class="rev-ok ms-1"><i class="bi bi-check-circle-fill"></i> {{ live_rev }}</span>
{% elif live_rev and not live_rev.startswith('<error') %}
<span class="rev-behind ms-1"><i class="bi bi-exclamation-circle-fill"></i> {{ live_rev }} (BEHIND)</span>
{% else %}
<span class="rev-error ms-1">{{ live_rev }}</span>
{% endif %}
</div>
</div>
</div>
</div>
{# ── Domains card ── #}
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white py-2">
<span class="fw-semibold" style="font-size:.9rem;"><i class="bi bi-globe me-1"></i>Domains</span>
</div>
<div class="table-responsive">
<table class="table table-sm mb-0 align-middle" style="font-size:.82rem;">
<thead class="table-light">
<tr>
<th>Domain</th>
<th>Kind</th>
<th>Primary</th>
<th>Verified</th>
<th>TLS</th>
<th>Token</th>
<th></th>
</tr>
</thead>
<tbody>
{% for d in domains %}
<tr>
<td class="font-monospace">{{ d.domain }}</td>
<td><span class="badge bg-secondary">{{ d.kind }}</span></td>
<td>{% if d.is_primary %}<i class="bi bi-check-circle-fill text-success"></i>{% endif %}</td>
<td>
{% if d.verified %}
<span class="text-success"><i class="bi bi-check-circle-fill"></i></span>
{% else %}
<form method="POST"
action="{{ url_for('tenants.verify_domain', tenant_id=tenant.id, domain_id=d.id) }}"
class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-xs btn-outline-success" style="font-size:.72rem; padding:.1rem .4rem;">
Verify
</button>
</form>
{% endif %}
</td>
<td><span class="badge {% if d.tls_status == 'active' %}bg-success{% elif d.tls_status == 'failed' %}bg-danger{% else %}bg-secondary{% endif %}">{{ d.tls_status }}</span></td>
<td class="font-monospace" style="font-size:.72rem; max-width:120px; overflow:hidden; text-overflow:ellipsis;">{{ d.verification_token or '—' }}</td>
<td>
{% if not d.is_primary %}
<form method="POST"
action="{{ url_for('tenants.delete_domain', tenant_id=tenant.id, domain_id=d.id) }}"
class="d-inline"
onsubmit="return confirm('Delete domain {{ d.domain }}?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-xs btn-outline-danger" style="font-size:.72rem; padding:.1rem .4rem;">
<i class="bi bi-trash"></i>
</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="7" class="text-muted py-2">No domains.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card-footer bg-white py-2">
<form method="POST"
action="{{ url_for('tenants.add_domain', tenant_id=tenant.id) }}"
class="row g-2 align-items-end">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="col-auto">
<input type="text" name="domain" class="form-control form-control-sm"
placeholder="custom.example.com" style="width:220px;">
</div>
<div class="col-auto">
<select name="kind" class="form-select form-select-sm">
<option value="custom">custom</option>
<option value="subdomain">subdomain</option>
</select>
</div>
<div class="col-auto">
<button class="btn btn-sm btn-outline-primary">
<i class="bi bi-plus-circle me-1"></i>Add Domain
</button>
</div>
</form>
</div>
</div>
{# ── Provisioning jobs ── #}
<div class="card border-0 shadow-sm">
<div class="card-header bg-white py-2">
<span class="fw-semibold" style="font-size:.9rem;"><i class="bi bi-list-check me-1"></i>Recent Jobs</span>
</div>
<div class="table-responsive">
<table class="table table-sm mb-0 align-middle" style="font-size:.8rem;">
<thead class="table-light">
<tr>
<th>#</th><th>Action</th><th>Status</th><th>Created</th><th>Log</th>
</tr>
</thead>
<tbody>
{% for j in jobs %}
<tr>
<td class="text-muted">{{ j.id }}</td>
<td>{{ j.action }}</td>
<td>
<span class="badge {% if j.status == 'ok' %}bg-success{% elif j.status == 'failed' %}bg-danger{% elif j.status == 'running' %}bg-info{% else %}bg-secondary{% endif %}">
{{ j.status }}
</span>
</td>
<td>{{ j.created_at.strftime('%m-%d %H:%M') if j.created_at else '—' }}</td>
<td class="text-muted" style="max-width:260px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
{{ j.log or '' }}
</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-muted py-2">No jobs recorded.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>{# /col-lg-8 #}
{# ═══ RIGHT COLUMN ═══ #}
<div class="col-lg-4">
{# ── Plan card ── #}
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white py-2">
<span class="fw-semibold" style="font-size:.9rem;"><i class="bi bi-patch-check me-1"></i>Plan</span>
</div>
<div class="card-body py-2">
<form method="POST"
action="{{ url_for('tenants.change_plan', tenant_id=tenant.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-2">
<select name="plan_id" class="form-select form-select-sm">
{% for p in plans %}
<option value="{{ p.id }}" {{ 'selected' if p.id == tenant.plan_id else '' }}>
{{ p.name }} ({{ p.code }})
</option>
{% endfor %}
</select>
</div>
<button class="btn btn-sm btn-outline-primary w-100">
<i class="bi bi-arrow-repeat me-1"></i> Change Plan
</button>
</form>
</div>
</div>
{# ── Status actions ── #}
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white py-2">
<span class="fw-semibold" style="font-size:.9rem;"><i class="bi bi-toggle-on me-1"></i>Status Actions</span>
</div>
<div class="card-body py-2 d-flex flex-column gap-2">
{# Impersonate #}
{% if tenant.status == 'active' %}
<a href="{{ url_for('tenants.impersonate', tenant_id=tenant.id) }}"
class="btn btn-sm btn-outline-secondary"
onclick="return confirm('Open tenant {{ tenant.slug }} as superadmin? You will be redirected to the tenant app.')">
<i class="bi bi-person-badge me-1"></i> Impersonate
</a>
{% endif %}
{# Suspend #}
{% if tenant.status not in ('suspended', 'deleted') %}
<button class="btn btn-sm btn-outline-warning" data-bs-toggle="modal" data-bs-target="#suspendModal">
<i class="bi bi-pause-circle me-1"></i> Suspend
</button>
{% endif %}
{# Resume #}
{% if tenant.status == 'suspended' %}
<form method="POST"
action="{{ url_for('tenants.resume_tenant', tenant_id=tenant.id) }}"
onsubmit="return confirm('Resume tenant {{ tenant.slug }}?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-sm btn-outline-success w-100">
<i class="bi bi-play-circle me-1"></i> Resume
</button>
</form>
{% endif %}
</div>
</div>
{# ── Quick info ── #}
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white py-2">
<span class="fw-semibold" style="font-size:.9rem;"><i class="bi bi-link-45deg me-1"></i>Primary URL</span>
</div>
<div class="card-body py-2" style="font-size:.82rem;">
{% set primary_domain = domains | selectattr('is_primary') | first %}
{% if primary_domain %}
<a href="https://{{ primary_domain.domain }}" target="_blank" rel="noopener"
class="text-decoration-none font-monospace">
https://{{ primary_domain.domain }}
<i class="bi bi-box-arrow-up-right ms-1 text-muted" style="font-size:.75rem;"></i>
</a>
{% else %}
<span class="text-muted">No primary domain.</span>
{% endif %}
</div>
</div>
{# ── Billing (MT-8) ── #}
<div class="card border-0 shadow-sm">
<div class="card-header bg-white py-2">
<span class="fw-semibold" style="font-size:.9rem;"><i class="bi bi-credit-card me-1"></i>Billing</span>
</div>
<div class="card-body py-2" style="font-size:.83rem;">
{# Read-only info #}
<div class="mb-2">
<span class="text-muted">Status</span>
<div>
{% if tenant.subscription_status == 'active' %}
<span class="badge bg-success">active</span>
{% elif tenant.subscription_status == 'trial' %}
<span class="badge bg-info text-dark">trial</span>
{% elif tenant.subscription_status == 'past_due' %}
<span class="badge bg-warning text-dark">past_due</span>
{% elif tenant.subscription_status == 'cancelled' %}
<span class="badge bg-danger">cancelled</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</div>
</div>
{% if tenant.trial_ends_at %}
<div class="mb-1"><span class="text-muted">Trial ends</span>
<div>{{ tenant.trial_ends_at.strftime('%Y-%m-%d') }}</div></div>
{% endif %}
{% if tenant.current_period_end %}
<div class="mb-1"><span class="text-muted">Period ends</span>
<div>{{ tenant.current_period_end.strftime('%Y-%m-%d') }}</div></div>
{% endif %}
{% if tenant.billing_email %}
<div class="mb-1"><span class="text-muted">Billing email</span>
<div>{{ tenant.billing_email }}</div></div>
{% endif %}
{% if tenant.stripe_customer_id %}
<div class="mb-1"><span class="text-muted">Stripe customer</span>
<div class="font-monospace" style="font-size:.72rem;" title="{{ tenant.stripe_customer_id }}">
{{ tenant.stripe_customer_id }}</div></div>
{% endif %}
{% if tenant.stripe_subscription_id %}
<div class="mb-2"><span class="text-muted">Stripe subscription</span>
<div class="font-monospace" style="font-size:.72rem;" title="{{ tenant.stripe_subscription_id }}">
{{ tenant.stripe_subscription_id }}</div></div>
{% endif %}
<hr class="my-2">
{# Set trial #}
<form method="POST"
action="{{ url_for('tenants.update_billing', tenant_id=tenant.id) }}"
class="mb-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="set_trial">
<label class="form-label mb-1" style="font-size:.8rem;">Extend / Set Trial</label>
<div class="input-group input-group-sm">
<input type="number" name="trial_days" value="14" min="1" max="365"
class="form-control" style="max-width:70px;">
<span class="input-group-text">days</span>
<button class="btn btn-outline-info btn-sm">Set Trial</button>
</div>
</form>
{# Override status #}
<form method="POST"
action="{{ url_for('tenants.update_billing', tenant_id=tenant.id) }}"
class="mb-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="set_status">
<label class="form-label mb-1" style="font-size:.8rem;">Override Status</label>
<div class="input-group input-group-sm">
<select name="subscription_status" class="form-select">
{% for st in ('trial','active','past_due','cancelled') %}
<option value="{{ st }}" {{ 'selected' if tenant.subscription_status == st }}>
{{ st }}
</option>
{% endfor %}
</select>
<button class="btn btn-outline-warning btn-sm">Set</button>
</div>
</form>
{# Apply coupon — only if Stripe customer exists #}
{% if tenant.stripe_customer_id %}
<form method="POST"
action="{{ url_for('tenants.update_billing', tenant_id=tenant.id) }}"
onsubmit="return confirm('Apply coupon to Stripe customer?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="apply_coupon">
<label class="form-label mb-1" style="font-size:.8rem;">Apply Stripe Coupon</label>
<div class="input-group input-group-sm">
<input type="text" name="coupon_id" class="form-control"
placeholder="COUPON_ID" required>
<button class="btn btn-outline-success btn-sm">Apply</button>
</div>
</form>
{% endif %}
</div>
</div>
</div>{# /col-lg-4 #}
</div>{# /row #}
{# ── Suspend modal ── #}
<div class="modal fade" id="suspendModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" style="font-size:.9rem;">Suspend {{ tenant.slug }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form method="POST" action="{{ url_for('tenants.suspend_tenant', tenant_id=tenant.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="modal-body">
<label class="form-label" style="font-size:.85rem;">Reason (optional)</label>
<input type="text" name="reason" class="form-control form-control-sm"
placeholder="e.g. non-payment" maxlength="255">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-sm btn-warning">Suspend</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}