419 lines
18 KiB
HTML
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 %}
|