Jun 27 MT-4
This commit is contained in:
@@ -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>
|
||||
@@ -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 — 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 %}
|
||||
Reference in New Issue
Block a user