Jun 27 MT-4

This commit is contained in:
2026-06-27 11:11:00 -04:00
parent 7b764c76af
commit 8895bcf762
11 changed files with 1342 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}JQC Control Panel{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<style>
body { background: #f1f5f9; font-family: -apple-system, 'Segoe UI', Roboto, sans-serif; }
.panel-sidebar {
width: 220px; min-height: 100vh; background: #0f172a; color: #cbd5e1;
position: fixed; top: 0; left: 0; padding: 0; z-index: 100;
display: flex; flex-direction: column;
}
.panel-sidebar .brand {
padding: 1.25rem 1.25rem 1rem;
border-bottom: 1px solid #1e293b;
font-weight: 700; font-size: .95rem; color: #f1f5f9;
letter-spacing: -.01em;
}
.panel-sidebar .brand .badge-sa {
background: #ef4444; color: #fff; font-size: .6rem;
padding: .15rem .4rem; border-radius: 4px; vertical-align: middle;
margin-left: .35rem; letter-spacing: .03em;
}
.panel-sidebar nav { padding: .75rem 0; flex: 1; }
.panel-sidebar nav a {
display: flex; align-items: center; gap: .6rem;
padding: .5rem 1.25rem; color: #94a3b8; text-decoration: none;
font-size: .875rem; border-left: 3px solid transparent;
transition: background .12s, color .12s;
}
.panel-sidebar nav a:hover, .panel-sidebar nav a.active {
background: #1e293b; color: #f1f5f9;
border-left-color: #3b82f6;
}
.panel-sidebar nav a i { font-size: 1rem; }
.panel-sidebar .sa-footer {
padding: .85rem 1.25rem; border-top: 1px solid #1e293b;
font-size: .78rem; color: #64748b;
}
.panel-sidebar .sa-footer a { color: #94a3b8; text-decoration: none; }
.panel-sidebar .sa-footer a:hover { color: #f1f5f9; }
.panel-main { margin-left: 220px; padding: 1.75rem 2rem; }
.panel-topbar {
background: #fff; border-bottom: 1px solid #e2e8f0;
padding: .75rem 2rem; margin-left: 220px;
position: sticky; top: 0; z-index: 50;
display: flex; align-items: center; justify-content: space-between;
}
.status-badge { display: inline-block; padding: .2rem .55rem; border-radius: 12px; font-size: .72rem; font-weight: 600; }
.status-active { background: #dcfce7; color: #15803d; }
.status-suspended { background: #fef9c3; color: #92400e; }
.status-provisioning { background: #dbeafe; color: #1d4ed8; }
.status-deleted { background: #fee2e2; color: #991b1b; }
.rev-ok { color: #15803d; }
.rev-behind { color: #d97706; font-weight: 600; }
.rev-error { color: #dc2626; font-size: .78rem; }
@media (max-width: 768px) {
.panel-sidebar { display: none; }
.panel-main, .panel-topbar { margin-left: 0; }
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
<div class="panel-sidebar">
<div class="brand">
<i class="bi bi-shield-lock-fill me-1 text-primary"></i>
JQC Control<span class="badge-sa">SUPER</span>
</div>
<nav>
<a href="{{ url_for('tenants.list_tenants') }}"
class="{{ 'active' if request.endpoint == 'tenants.list_tenants' else '' }}">
<i class="bi bi-building"></i> Tenants
</a>
<a href="{{ url_for('tenants.provision_tenant') }}"
class="{{ 'active' if request.endpoint == 'tenants.provision_tenant' else '' }}">
<i class="bi bi-plus-circle"></i> New Tenant
</a>
</nav>
<div class="sa-footer">
{% if sa_username %}
Signed in as <strong>{{ sa_username }}</strong><br>
<a href="{{ url_for('auth.logout') }}"><i class="bi bi-box-arrow-left me-1"></i>Logout</a>
{% endif %}
</div>
</div>
<div class="panel-topbar">
<span class="fw-semibold text-muted" style="font-size:.9rem;">
{% block page_title %}Control Panel{% endblock %}
</span>
</div>
<div class="panel-main">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'danger' else ('warning' if category == 'warning' else ('success' if category == 'success' else 'info')) }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
</body>
</html>
+61
View File
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Control Panel Login — JQC</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<style>
body { background: #0f172a; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
.login-card {
background: #1e293b; border-radius: 14px; padding: 2.5rem 2.25rem;
width: 100%; max-width: 380px; box-shadow: 0 20px 60px rgba(0,0,0,.4);
}
.login-card .logo { font-size: 2rem; color: #3b82f6; }
.login-card h1 { color: #f1f5f9; font-size: 1.3rem; font-weight: 700; margin-top: .5rem; }
.login-card .sub { color: #64748b; font-size: .85rem; margin-bottom: 1.75rem; }
.login-card label { color: #94a3b8; font-size: .85rem; }
.login-card .form-control {
background: #0f172a; border-color: #334155; color: #f1f5f9;
}
.login-card .form-control:focus {
background: #0f172a; border-color: #3b82f6;
box-shadow: 0 0 0 .2rem rgba(59,130,246,.25); color: #f1f5f9;
}
.login-card .form-control::placeholder { color: #475569; }
.btn-panel { background: #3b82f6; border-color: #3b82f6; color: #fff; font-weight: 600; }
.btn-panel:hover { background: #2563eb; border-color: #2563eb; color: #fff; }
.error-box { background: #450a0a; border: 1px solid #7f1d1d; color: #fca5a5;
border-radius: 8px; padding: .65rem 1rem; font-size: .85rem; margin-bottom: 1rem; }
.footer-note { color: #334155; font-size: .72rem; text-align: center; margin-top: 1.5rem; }
</style>
</head>
<body>
<div class="login-card">
<div class="logo"><i class="bi bi-shield-lock-fill"></i></div>
<h1>Control Panel</h1>
<p class="sub">Superadmin access only.</p>
{% if error %}
<div class="error-box"><i class="bi bi-exclamation-triangle me-1"></i>{{ error }}</div>
{% endif %}
<form method="POST" action="{{ url_for('auth.login') }}">
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" name="username" class="form-control" placeholder="superadmin" required autofocus>
</div>
<div class="mb-4">
<label class="form-label">Password</label>
<input type="password" name="password" class="form-control" placeholder="••••••••" required>
</div>
<button type="submit" class="btn btn-panel w-100">
<i class="bi bi-box-arrow-in-right me-1"></i> Sign In
</button>
</form>
<p class="footer-note">JQC Superadmin Panel &mdash; authorised personnel only.</p>
</div>
</body>
</html>
@@ -0,0 +1,77 @@
{% extends "panel/base.html" %}
{% block title %}New Tenant — JQC Control{% endblock %}
{% block page_title %}Provision New Tenant{% endblock %}
{% block content %}
<div class="row">
<div class="col-lg-6">
<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-plus-circle me-1"></i>New Tenant</span>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('tenants.provision_tenant') }}">
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:.85rem;">Slug <span class="text-danger">*</span></label>
<input type="text" name="slug" class="form-control form-control-sm" required
pattern="^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$"
placeholder="e.g. acme" maxlength="63">
<div class="form-text" style="font-size:.75rem;">DNS label — lowercase letters, digits, hyphens only.</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:.85rem;">Company Name <span class="text-danger">*</span></label>
<input type="text" name="name" class="form-control form-control-sm" required
placeholder="Acme Janitorial Inc." maxlength="150">
</div>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:.85rem;">Plan <span class="text-danger">*</span></label>
<select name="plan_code" class="form-select form-select-sm" required>
{% for p in plans %}
<option value="{{ p.code }}">{{ p.name }} ({{ p.code }})</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold" style="font-size:.85rem;">Admin Email <span class="text-danger">*</span></label>
<input type="email" name="admin_email" class="form-control form-control-sm" required
placeholder="admin@acme.com">
<div class="form-text" style="font-size:.75rem;">Setup link will be sent to this address.</div>
</div>
<div class="mb-3">
<label class="form-label" style="font-size:.85rem;">Admin Username <span class="text-muted">(optional)</span></label>
<input type="text" name="admin_username" class="form-control form-control-sm"
placeholder="Defaults to email prefix">
</div>
<div class="mb-4">
<label class="form-label" style="font-size:.85rem;">Custom Domain <span class="text-muted">(optional)</span></label>
<input type="text" name="custom_domain" class="form-control form-control-sm"
placeholder="jqc.acme.com">
<div class="form-text" style="font-size:.75rem;">Leave blank to use subdomain only.</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-rocket-takeoff me-1"></i> Provision Tenant
</button>
<a href="{{ url_for('tenants.list_tenants') }}" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
</form>
</div>
</div>
<div class="card border-0 shadow-sm mt-3">
<div class="card-body py-2" style="font-size:.8rem; color:#64748b;">
<i class="bi bi-info-circle me-1"></i>
Provisioning will: create MySQL DB + user, build schema, seed admin account,
register subdomain. The setup link in the success message must be sent to the admin manually.
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,309 @@
{% 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 }}?')">
<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">
<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 }}?')">
<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">
<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) }}">
<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 }}?')">
<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">
<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>
</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) }}">
<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 %}
@@ -0,0 +1,69 @@
{% extends "panel/base.html" %}
{% block title %}Tenants — JQC Control{% endblock %}
{% block page_title %}Tenants{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<span class="text-muted" style="font-size:.85rem;">
Chain head: <code>{{ chain_head }}</code>
</span>
</div>
<a href="{{ url_for('tenants.provision_tenant') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle me-1"></i> New Tenant
</a>
</div>
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th style="width:40px">#</th>
<th>Slug / Name</th>
<th>Status</th>
<th>Plan</th>
<th>Schema</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
{% for t in tenants %}
<tr>
<td class="text-muted" style="font-size:.8rem;">{{ t.id }}</td>
<td>
<div class="fw-semibold" style="font-size:.9rem;">{{ t.slug }}</div>
<div class="text-muted" style="font-size:.78rem;">{{ t.name }}</div>
</td>
<td>
<span class="status-badge status-{{ t.status }}">{{ t.status }}</span>
</td>
<td style="font-size:.85rem;">{{ t.plan_name }}</td>
<td style="font-size:.78rem; font-family: monospace;">
{% if t.alembic_head == chain_head %}
<span class="rev-ok"><i class="bi bi-check-circle-fill me-1"></i>current</span>
{% elif t.alembic_head %}
<span class="rev-behind"><i class="bi bi-exclamation-circle-fill me-1"></i>{{ t.alembic_head[:12] }}…</span>
{% else %}
<span class="text-muted">unknown</span>
{% endif %}
</td>
<td style="font-size:.78rem; color:#64748b;">
{{ t.created_at.strftime('%Y-%m-%d') if t.created_at else '—' }}
</td>
<td>
<a href="{{ url_for('tenants.tenant_detail', tenant_id=t.id) }}"
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-right"></i>
</a>
</td>
</tr>
{% else %}
<tr><td colspan="7" class="text-center text-muted py-4">No tenants yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}