gui: redesign WebRTC admin page with Wild Dragon brand
- Rajdhani + JetBrains Mono typefaces via Google Fonts - Deep dark palette: #09090d bg, #ff5c28 accent - 22px dot-grid background texture - Sticky frosted-glass header with WD monogram SVG - Wild Dragon wordmark SVG on login panel - Process cards with border-left state indicator (green/amber/red) - Animated pulse dots on state badges - Cleaner WHEP URL row with Copy + Open buttons - Log panels hidden until first entry (no empty box on load) - All JS functionality preserved (JWT auth, toggle+restart, WHEP copy)
This commit is contained in:
parent
dd639b697f
commit
70d0ddb2e3
1 changed files with 628 additions and 399 deletions
|
|
@ -2,217 +2,377 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Dragon Fork — WebRTC Admin</title>
|
<title>Wild Dragon — WebRTC Admin</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Rajdhani:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light dark;
|
--bg: #09090d;
|
||||||
--fg: #e7e7ea;
|
--panel: #111118;
|
||||||
--bg: #0d0e12;
|
--panel2: #16161f;
|
||||||
--accent: #ff6633;
|
--border: #1f1f2e;
|
||||||
--muted: #8b8e98;
|
--border2: #2a2a3d;
|
||||||
--good: #5dd29c;
|
--fg: #e8e8f0;
|
||||||
--warn: #ffb45e;
|
--fg2: #9898b0;
|
||||||
--bad: #ff6470;
|
--accent: #ff5c28;
|
||||||
--panel: #1a1c22;
|
--accent2: #ff7a4a;
|
||||||
--border: #232530;
|
--good: #4ecb8d;
|
||||||
|
--bad: #ff4d60;
|
||||||
|
--warn: #f5a623;
|
||||||
|
--mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
font-family: 'Rajdhani', system-ui, sans-serif;
|
||||||
font: 14px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.5;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
/* dot grid */
|
||||||
|
background-image: radial-gradient(circle, #1e1e30 1px, transparent 1px);
|
||||||
|
background-size: 22px 22px;
|
||||||
|
background-attachment: fixed;
|
||||||
}
|
}
|
||||||
header {
|
|
||||||
padding: 1.25rem 1.5rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.75rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
header h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
header h1 .accent { color: var(--accent); }
|
|
||||||
header .subtitle { color: var(--muted); font-size: 0.85rem; }
|
|
||||||
header .spacer { flex: 1; }
|
|
||||||
header .nav a {
|
|
||||||
color: var(--muted);
|
|
||||||
text-decoration: none;
|
|
||||||
margin-left: 1rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
header .nav a:hover { color: var(--accent); }
|
|
||||||
|
|
||||||
|
/* ── HEADER ─────────────────────────────────────────────────── */
|
||||||
|
header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
height: 52px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 24px;
|
||||||
|
background: rgba(9,9,13,0.88);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* WD monogram — 28×28 */
|
||||||
|
.header-monogram {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 22px;
|
||||||
|
background: var(--border2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.header-title span { color: var(--accent); }
|
||||||
|
|
||||||
|
.header-spacer { flex: 1; }
|
||||||
|
|
||||||
|
nav a {
|
||||||
|
color: var(--fg2);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-left: 20px;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
nav a:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── MAIN ───────────────────────────────────────────────────── */
|
||||||
main {
|
main {
|
||||||
padding: 1.5rem;
|
flex: 1;
|
||||||
max-width: 1200px;
|
padding: 32px 24px;
|
||||||
|
max-width: 960px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
flex: 1;
|
}
|
||||||
|
|
||||||
|
/* ── LOGIN PANEL ────────────────────────────────────────────── */
|
||||||
|
#login-panel {
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 48px auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-wordmark {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border-radius: 10px;
|
border: 1px solid var(--border);
|
||||||
padding: 1.25rem;
|
border-radius: 12px;
|
||||||
margin-bottom: 1rem;
|
padding: 24px;
|
||||||
}
|
margin-bottom: 16px;
|
||||||
.panel h2 {
|
|
||||||
margin: 0 0 0.75rem 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.row {
|
.panel-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
.panel-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--fg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── FORM ELEMENTS ──────────────────────────────────────────── */
|
||||||
|
.field {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.field label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 0.5rem;
|
font-size: 11px;
|
||||||
color: var(--muted);
|
font-weight: 700;
|
||||||
font-size: 0.78rem;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
color: var(--fg2);
|
||||||
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
input[type=text], input[type=password] {
|
.field input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.55rem 0.7rem;
|
padding: 10px 12px;
|
||||||
margin-top: 0.25rem;
|
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 1px solid #2a2c36;
|
border: 1px solid var(--border2);
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--fg);
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
input[type=text]:focus, input[type=password]:focus {
|
|
||||||
border-color: var(--accent);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding: 0.55rem 1rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: #000;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
||||||
button.secondary { background: #2a2c36; color: var(--fg); }
|
|
||||||
button.danger { background: rgba(255,100,112,0.20); color: var(--bad); }
|
|
||||||
button.small { padding: 0.35rem 0.7rem; font-size: 0.85rem; }
|
|
||||||
|
|
||||||
.pill {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.1rem 0.55rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
background: #2a2c36;
|
|
||||||
}
|
|
||||||
.pill.good { background: rgba(93,210,156,0.18); color: var(--good); }
|
|
||||||
.pill.warn { background: rgba(255,180,94,0.18); color: var(--warn); }
|
|
||||||
.pill.bad { background: rgba(255,100,112,0.20); color: var(--bad); }
|
|
||||||
|
|
||||||
.process-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
|
||||||
.process {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr auto;
|
|
||||||
gap: 0.5rem 1rem;
|
|
||||||
padding: 0.9rem 1rem;
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
.process .id {
|
.field input:focus {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
outline: none;
|
||||||
font-weight: 600;
|
border-color: var(--accent);
|
||||||
word-break: break-all;
|
box-shadow: 0 0 0 3px rgba(255,92,40,0.12);
|
||||||
}
|
}
|
||||||
.process .meta {
|
|
||||||
color: var(--muted);
|
/* ── BUTTONS ────────────────────────────────────────────────── */
|
||||||
font-size: 0.8rem;
|
.btn {
|
||||||
}
|
display: inline-flex;
|
||||||
.process .actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.4rem;
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
.process .whep-url {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--accent);
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.4rem 0.6rem;
|
|
||||||
word-break: break-all;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 6px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s, transform 0.1s;
|
||||||
}
|
}
|
||||||
.process .whep-url a {
|
.btn:active { transform: scale(0.97); }
|
||||||
color: var(--muted);
|
.btn:disabled { opacity: 0.35; cursor: not-allowed; transform: none; }
|
||||||
text-decoration: none;
|
.btn-primary { background: var(--accent); color: #fff; }
|
||||||
margin-left: auto;
|
.btn-primary:hover:not(:disabled) { background: var(--accent2); }
|
||||||
|
.btn-secondary { background: var(--panel2); color: var(--fg); border: 1px solid var(--border2); }
|
||||||
|
.btn-secondary:hover:not(:disabled) { border-color: var(--fg2); }
|
||||||
|
.btn-danger { background: rgba(255,77,96,0.12); color: var(--bad); border: 1px solid rgba(255,77,96,0.25); }
|
||||||
|
.btn-danger:hover:not(:disabled) { background: rgba(255,77,96,0.22); }
|
||||||
|
.btn-sm { padding: 6px 12px; font-size: 12px; }
|
||||||
|
|
||||||
|
/* ── BADGES ─────────────────────────────────────────────────── */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.badge-good { background: rgba(78,203,141,0.12); color: var(--good); }
|
||||||
|
.badge-bad { background: rgba(255,77,96,0.12); color: var(--bad); }
|
||||||
|
.badge-warn { background: rgba(245,166,35,0.12); color: var(--warn); }
|
||||||
|
.badge-neutral { background: var(--panel2); color: var(--fg2); }
|
||||||
|
.badge-accent { background: rgba(255,92,40,0.12); color: var(--accent); }
|
||||||
|
|
||||||
|
/* animated pulse dot */
|
||||||
|
.pulse-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.process .whep-url a:hover { color: var(--accent); }
|
.pulse-dot.good { background: var(--good); box-shadow: 0 0 0 0 rgba(78,203,141,0.4); animation: pulse-green 2s infinite; }
|
||||||
|
.pulse-dot.bad { background: var(--bad); box-shadow: 0 0 0 0 rgba(255,77,96,0.4); animation: pulse-red 2s infinite; }
|
||||||
|
.pulse-dot.warn { background: var(--warn); box-shadow: 0 0 0 0 rgba(245,166,35,0.4); animation: pulse-warn 2s infinite; }
|
||||||
|
|
||||||
.empty {
|
@keyframes pulse-green {
|
||||||
color: var(--muted);
|
0% { box-shadow: 0 0 0 0 rgba(78,203,141,0.5); }
|
||||||
text-align: center;
|
70% { box-shadow: 0 0 0 6px rgba(78,203,141,0); }
|
||||||
padding: 2rem 0;
|
100% { box-shadow: 0 0 0 0 rgba(78,203,141,0); }
|
||||||
|
}
|
||||||
|
@keyframes pulse-red {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(255,77,96,0.5); }
|
||||||
|
70% { box-shadow: 0 0 0 6px rgba(255,77,96,0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(255,77,96,0); }
|
||||||
|
}
|
||||||
|
@keyframes pulse-warn {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(245,166,35,0.5); }
|
||||||
|
70% { box-shadow: 0 0 0 6px rgba(245,166,35,0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(245,166,35,0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── PROCESS CARDS ──────────────────────────────────────────── */
|
||||||
|
.process-list { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
|
||||||
|
.process-card {
|
||||||
|
background: var(--panel2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--border2);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 12px 16px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.process-card.state-running { border-left-color: var(--good); }
|
||||||
|
.process-card.state-failed { border-left-color: var(--bad); }
|
||||||
|
.process-card.state-connecting { border-left-color: var(--warn); }
|
||||||
|
.process-card.state-finished { border-left-color: var(--fg2); }
|
||||||
|
|
||||||
|
.process-id {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg);
|
||||||
|
word-break: break-all;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-ref {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg2);
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whep-row {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgba(255,92,40,0.06);
|
||||||
|
border: 1px solid rgba(255,92,40,0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.whep-row .whep-label {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--fg2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.whep-row .whep-url-text { flex: 1; }
|
||||||
|
.whep-row .whep-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* ── LOG ────────────────────────────────────────────────────── */
|
||||||
.log {
|
.log {
|
||||||
margin-top: 1rem;
|
margin-top: 16px;
|
||||||
max-height: 180px;
|
max-height: 160px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 0.6rem 0.8rem;
|
border-radius: 8px;
|
||||||
border-radius: 6px;
|
padding: 10px 14px;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
font-family: var(--mono);
|
||||||
font-size: 0.78rem;
|
font-size: 12px;
|
||||||
line-height: 1.4;
|
line-height: 1.6;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
.log .ts { color: var(--muted); }
|
.log .ts { color: var(--fg2); }
|
||||||
.log .lvl-bad { color: var(--bad); }
|
.log .l-bad { color: var(--bad); }
|
||||||
.log .lvl-good { color: var(--good); }
|
.log .l-good { color: var(--good); }
|
||||||
.log .lvl-warn { color: var(--warn); }
|
.log .l-warn { color: var(--warn); }
|
||||||
|
|
||||||
/* Login screen takes the full panel by itself */
|
/* ── EMPTY STATE ─────────────────────────────────────────────── */
|
||||||
#login-panel { max-width: 420px; margin: 4rem auto 0; }
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: var(--fg2);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── ROW UTIL ────────────────────────────────────────────────── */
|
||||||
|
.row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<!-- ── HEADER ──────────────────────────────────────────────────── -->
|
||||||
<header>
|
<header>
|
||||||
<h1>Dragon Fork <span class="accent">WebRTC Admin</span></h1>
|
<a class="header-logo" href="/">
|
||||||
<span class="subtitle">enable / disable WebRTC egress per process</span>
|
<!-- WD monogram 28×28 -->
|
||||||
<span class="spacer"></span>
|
<svg class="header-monogram" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
<nav class="nav">
|
<rect width="28" height="28" rx="6" fill="#1a1a24"/>
|
||||||
|
<!-- flame chevron -->
|
||||||
|
<path d="M14 20 L8 10 L11.5 13 L14 8 L16.5 13 L20 10 Z" fill="#ff5c28" opacity="0.9"/>
|
||||||
|
<!-- WD text mark -->
|
||||||
|
<text x="3.5" y="26" font-family="'Rajdhani',sans-serif" font-size="8.5" font-weight="700" fill="#e8e8f0" letter-spacing="0.5">WD</text>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<div class="header-divider"></div>
|
||||||
|
<span class="header-title">WebRTC <span>Admin</span></span>
|
||||||
|
<div class="header-spacer"></div>
|
||||||
|
<nav>
|
||||||
<a href="/">Restreamer</a>
|
<a href="/">Restreamer</a>
|
||||||
<a href="/whep-player.html">WHEP Player</a>
|
<a href="/whep-player.html">WHEP Player</a>
|
||||||
<a href="/api/swagger/index.html">API</a>
|
<a href="/api/swagger/index.html">API</a>
|
||||||
|
|
@ -220,58 +380,95 @@
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- ── MAIN ────────────────────────────────────────────────────── -->
|
||||||
<main>
|
<main>
|
||||||
|
|
||||||
<!-- LOGIN PANEL -->
|
<!-- LOGIN PANEL -->
|
||||||
<section id="login-panel" class="panel">
|
<section id="login-panel">
|
||||||
<h2>Sign in</h2>
|
|
||||||
<p style="color: var(--muted); margin: 0 0 0.75rem 0;">
|
<!-- Wild Dragon wordmark SVG -->
|
||||||
Use the same credentials as Core's API
|
<svg class="login-wordmark" width="220" height="48" viewBox="0 0 220 48" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="Wild Dragon">
|
||||||
(<code>API_AUTH_USERNAME</code> / <code>API_AUTH_PASSWORD</code>).
|
<!-- rounded dark rect icon -->
|
||||||
|
<rect x="0" y="4" width="40" height="40" rx="8" fill="#1a1a24"/>
|
||||||
|
<!-- flame gradient defs -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="flameG" x1="20" y1="38" x2="20" y2="12" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#ff5c28"/>
|
||||||
|
<stop offset="100%" stop-color="#ffaa44"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<!-- flame/chevron -->
|
||||||
|
<path d="M20 36 L11 20 L16 24 L20 14 L24 24 L29 20 Z" fill="url(#flameG)"/>
|
||||||
|
<!-- W mark -->
|
||||||
|
<text x="5" y="44" font-family="'Rajdhani',sans-serif" font-size="13" font-weight="700" fill="#c8c8e0" letter-spacing="1">WD</text>
|
||||||
|
<!-- WILD text -->
|
||||||
|
<text x="50" y="26" font-family="'Rajdhani',sans-serif" font-size="20" font-weight="300" letter-spacing="4" fill="#e8e8f0">WILD</text>
|
||||||
|
<!-- DRAGON text -->
|
||||||
|
<text x="50" y="44" font-family="'Rajdhani',sans-serif" font-size="20" font-weight="700" letter-spacing="4" fill="#ff5c28">DRAGON</text>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">Sign in</span>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:14px; color:var(--fg2); margin-bottom:18px;">
|
||||||
|
Use your Core API credentials (<code style="font-family:var(--mono);font-size:12px;color:var(--fg)">API_AUTH_USERNAME</code> / <code style="font-family:var(--mono);font-size:12px;color:var(--fg)">API_AUTH_PASSWORD</code>).
|
||||||
</p>
|
</p>
|
||||||
|
<div class="field">
|
||||||
<label for="login-user">Username</label>
|
<label for="login-user">Username</label>
|
||||||
<input id="login-user" type="text" autocomplete="username">
|
<input id="login-user" type="text" autocomplete="username" placeholder="admin">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
<label for="login-pass">Password</label>
|
<label for="login-pass">Password</label>
|
||||||
<input id="login-pass" type="password" autocomplete="current-password">
|
<input id="login-pass" type="password" autocomplete="current-password">
|
||||||
<div class="row" style="margin-top: 1rem;">
|
|
||||||
<button id="btn-login">Sign in</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="login-log" class="log" style="margin-top: 1rem;" aria-live="polite"></div>
|
<div class="row" style="margin-top:18px;">
|
||||||
|
<button class="btn btn-primary" id="btn-login">Sign in</button>
|
||||||
|
</div>
|
||||||
|
<div id="login-log" class="log" aria-live="polite" style="display:none"></div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ADMIN PANEL -->
|
<!-- ADMIN PANEL -->
|
||||||
<section id="admin-panel" class="panel" style="display:none">
|
<section id="admin-panel" style="display:none">
|
||||||
<div class="row" style="justify-content: space-between; margin-bottom: 0.75rem;">
|
<div class="panel">
|
||||||
<h2 style="margin: 0;">Processes</h2>
|
<div class="panel-header">
|
||||||
<div class="row" style="gap: 0.4rem;">
|
<span class="panel-title">Processes</span>
|
||||||
<button id="btn-refresh" class="secondary small">Refresh</button>
|
<div class="row" style="gap:6px;">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="btn-refresh">↻ Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="process-list" class="process-list">
|
<div id="process-list" class="process-list">
|
||||||
<div class="empty">Loading...</div>
|
<div class="empty">Loading…</div>
|
||||||
|
</div>
|
||||||
|
<div id="admin-log" class="log" aria-live="polite" style="display:none"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="admin-log" class="log" style="margin-top: 1rem;" aria-live="polite"></div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// --- tiny state -------------------------------------------------
|
// ── tiny state ────────────────────────────────────────────────────
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const TOKEN_KEY = 'dragonfork-admin-token';
|
const TOKEN_KEY = 'dragonfork-admin-token';
|
||||||
let authToken = null;
|
let authToken = null;
|
||||||
|
|
||||||
function log(panel, line, level = 'info') {
|
function log(panel, line, level = 'info') {
|
||||||
|
panel.style.display = '';
|
||||||
const ts = new Date().toLocaleTimeString();
|
const ts = new Date().toLocaleTimeString();
|
||||||
|
const cls = level === 'bad' ? 'l-bad' : level === 'good' ? 'l-good' : level === 'warn' ? 'l-warn' : '';
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.innerHTML = `<span class="ts">${ts}</span> <span class="lvl-${level}">${escapeHTML(line)}</span>`;
|
div.innerHTML = `<span class="ts">${ts}</span> <span class="${cls}">${escapeHTML(line)}</span>`;
|
||||||
panel.prepend(div);
|
panel.prepend(div);
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHTML(s) {
|
function escapeHTML(s) {
|
||||||
return String(s).replace(/[&<>"']/g, (c) => ({
|
return String(s).replace(/[&<>"']/g, (c) => ({
|
||||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||||
})[c]);
|
})[c]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- auth -------------------------------------------------------
|
// ── auth ──────────────────────────────────────────────────────────
|
||||||
function setAuth(token) {
|
function setAuth(token) {
|
||||||
authToken = token;
|
authToken = token;
|
||||||
if (token) {
|
if (token) {
|
||||||
|
|
@ -325,15 +522,13 @@
|
||||||
$('login-user').addEventListener('keydown', (e) => { if (e.key === 'Enter') login(); });
|
$('login-user').addEventListener('keydown', (e) => { if (e.key === 'Enter') login(); });
|
||||||
$('logout-link').addEventListener('click', (e) => { e.preventDefault(); setAuth(null); });
|
$('logout-link').addEventListener('click', (e) => { e.preventDefault(); setAuth(null); });
|
||||||
|
|
||||||
// Restore session if we have a stashed token. The first /process call
|
// Restore cached session (will bounce back to login on 401)
|
||||||
// will fail with 401 if it's expired; we catch that and bounce back to
|
|
||||||
// the login panel rather than asking the user to clear localStorage.
|
|
||||||
try {
|
try {
|
||||||
const cached = localStorage.getItem(TOKEN_KEY);
|
const cached = localStorage.getItem(TOKEN_KEY);
|
||||||
if (cached) setAuth(cached);
|
if (cached) setAuth(cached);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
// --- API helpers ------------------------------------------------
|
// ── API helpers ───────────────────────────────────────────────────
|
||||||
async function api(method, path, body) {
|
async function api(method, path, body) {
|
||||||
const headers = { 'Authorization': 'Bearer ' + authToken };
|
const headers = { 'Authorization': 'Bearer ' + authToken };
|
||||||
const init = { method, headers };
|
const init = { method, headers };
|
||||||
|
|
@ -343,7 +538,6 @@
|
||||||
}
|
}
|
||||||
const r = await fetch(path, init);
|
const r = await fetch(path, init);
|
||||||
if (r.status === 401) {
|
if (r.status === 401) {
|
||||||
// Stale token. Bounce to login.
|
|
||||||
log($('admin-log'), 'session expired, please sign in again', 'warn');
|
log($('admin-log'), 'session expired, please sign in again', 'warn');
|
||||||
setAuth(null);
|
setAuth(null);
|
||||||
throw new Error('unauthorized');
|
throw new Error('unauthorized');
|
||||||
|
|
@ -356,16 +550,13 @@
|
||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- process list -----------------------------------------------
|
// ── process list ──────────────────────────────────────────────────
|
||||||
$('btn-refresh').addEventListener('click', refreshProcesses);
|
$('btn-refresh').addEventListener('click', refreshProcesses);
|
||||||
|
|
||||||
async function refreshProcesses() {
|
async function refreshProcesses() {
|
||||||
if (!authToken) return;
|
if (!authToken) return;
|
||||||
$('process-list').innerHTML = '<div class="empty">Loading...</div>';
|
$('process-list').innerHTML = '<div class="empty">Loading…</div>';
|
||||||
try {
|
try {
|
||||||
// The list endpoint paginates via ?id=&filter=&order=. Without
|
|
||||||
// params it returns up to a server-side cap which is plenty for
|
|
||||||
// a 1-5 stream deploy. ?filter=config keeps the response small.
|
|
||||||
const procs = await api('GET', '/api/v3/process?filter=config,state');
|
const procs = await api('GET', '/api/v3/process?filter=config,state');
|
||||||
renderProcesses(procs);
|
renderProcesses(procs);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -375,6 +566,26 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stateClass(state) {
|
||||||
|
if (state === 'running') return 'state-running';
|
||||||
|
if (state === 'failed' || state === 'killed') return 'state-failed';
|
||||||
|
if (state === 'finishing') return 'state-finished';
|
||||||
|
return 'state-connecting'; // starting, reconnecting, idle, etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateBadgeClass(state) {
|
||||||
|
if (state === 'running') return 'badge-good';
|
||||||
|
if (state === 'failed' || state === 'killed') return 'badge-bad';
|
||||||
|
if (state === 'finishing') return 'badge-neutral';
|
||||||
|
return 'badge-warn';
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateDotClass(state) {
|
||||||
|
if (state === 'running') return 'good';
|
||||||
|
if (state === 'failed' || state === 'killed') return 'bad';
|
||||||
|
return 'warn';
|
||||||
|
}
|
||||||
|
|
||||||
function renderProcesses(procs) {
|
function renderProcesses(procs) {
|
||||||
const list = $('process-list');
|
const list = $('process-list');
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
|
|
@ -389,86 +600,104 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderProcess(proc) {
|
function renderProcess(proc) {
|
||||||
const el = document.createElement('div');
|
|
||||||
el.className = 'process';
|
|
||||||
|
|
||||||
const cfg = proc.config || {};
|
const cfg = proc.config || {};
|
||||||
const webrtcEnabled = !!(cfg.webrtc && cfg.webrtc.enabled);
|
const webrtcEnabled = !!(cfg.webrtc && cfg.webrtc.enabled);
|
||||||
const state = (proc.state && proc.state.exec) || (proc.state && proc.state.state) || 'unknown';
|
const state = (proc.state && proc.state.exec) || (proc.state && proc.state.state) || 'unknown';
|
||||||
const stateKlass = state === 'running' ? 'good' : (state === 'failed' ? 'bad' : 'warn');
|
|
||||||
|
|
||||||
// Left side: id + meta
|
const card = document.createElement('div');
|
||||||
|
card.className = 'process-card ' + stateClass(state);
|
||||||
|
|
||||||
|
// ── left ──
|
||||||
const left = document.createElement('div');
|
const left = document.createElement('div');
|
||||||
left.innerHTML = `
|
|
||||||
<div class="id">${escapeHTML(proc.id || '(no id)')}</div>
|
|
||||||
<div class="meta">
|
|
||||||
<span class="pill ${stateKlass}">${escapeHTML(state)}</span>
|
|
||||||
WebRTC: <span class="pill ${webrtcEnabled ? 'good' : ''}">${webrtcEnabled ? 'enabled' : 'disabled'}</span>
|
|
||||||
${cfg.reference ? '· ref: ' + escapeHTML(cfg.reference) : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
el.appendChild(left);
|
|
||||||
|
|
||||||
// Right side: action buttons
|
// id
|
||||||
|
const idEl = document.createElement('div');
|
||||||
|
idEl.className = 'process-id';
|
||||||
|
idEl.textContent = proc.id || '(no id)';
|
||||||
|
left.appendChild(idEl);
|
||||||
|
|
||||||
|
// meta row: state badge + webrtc badge + ref
|
||||||
|
const meta = document.createElement('div');
|
||||||
|
meta.className = 'process-meta';
|
||||||
|
|
||||||
|
const dotCls = stateDotClass(state);
|
||||||
|
const badgeCls = stateBadgeClass(state);
|
||||||
|
const stateBadge = document.createElement('span');
|
||||||
|
stateBadge.className = 'badge ' + badgeCls;
|
||||||
|
stateBadge.innerHTML = `<span class="pulse-dot ${dotCls}"></span>${escapeHTML(state)}`;
|
||||||
|
meta.appendChild(stateBadge);
|
||||||
|
|
||||||
|
const webrtcBadge = document.createElement('span');
|
||||||
|
webrtcBadge.className = 'badge ' + (webrtcEnabled ? 'badge-accent' : 'badge-neutral');
|
||||||
|
webrtcBadge.textContent = 'WebRTC ' + (webrtcEnabled ? 'on' : 'off');
|
||||||
|
meta.appendChild(webrtcBadge);
|
||||||
|
|
||||||
|
if (cfg.reference) {
|
||||||
|
const ref = document.createElement('span');
|
||||||
|
ref.className = 'process-ref';
|
||||||
|
ref.textContent = cfg.reference;
|
||||||
|
meta.appendChild(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
left.appendChild(meta);
|
||||||
|
card.appendChild(left);
|
||||||
|
|
||||||
|
// ── right: actions ──
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'actions';
|
actions.className = 'process-actions';
|
||||||
const toggle = document.createElement('button');
|
|
||||||
toggle.className = webrtcEnabled ? 'danger small' : 'small';
|
|
||||||
toggle.textContent = webrtcEnabled ? 'Disable WebRTC' : 'Enable WebRTC';
|
|
||||||
toggle.addEventListener('click', () => toggleWebRTC(proc.id, !webrtcEnabled, toggle));
|
|
||||||
actions.appendChild(toggle);
|
|
||||||
el.appendChild(actions);
|
|
||||||
|
|
||||||
// Bottom row: WHEP URL with copy and "open in player"
|
const toggleBtn = document.createElement('button');
|
||||||
|
toggleBtn.className = 'btn btn-sm ' + (webrtcEnabled ? 'btn-danger' : 'btn-secondary');
|
||||||
|
toggleBtn.textContent = webrtcEnabled ? 'Disable WebRTC' : 'Enable WebRTC';
|
||||||
|
toggleBtn.addEventListener('click', () => toggleWebRTC(proc.id, !webrtcEnabled, toggleBtn));
|
||||||
|
actions.appendChild(toggleBtn);
|
||||||
|
card.appendChild(actions);
|
||||||
|
|
||||||
|
// ── WHEP URL row ──
|
||||||
if (webrtcEnabled) {
|
if (webrtcEnabled) {
|
||||||
const whepURL = location.origin + '/api/v3/whep/' + encodeURIComponent(proc.id);
|
const whepURL = location.origin + '/api/v3/whep/' + encodeURIComponent(proc.id);
|
||||||
const playerURL = '/whep-player.html?url=' + encodeURIComponent(whepURL);
|
const playerURL = '/whep-player.html?url=' + encodeURIComponent(whepURL);
|
||||||
const w = document.createElement('div');
|
|
||||||
w.className = 'whep-url';
|
const row = document.createElement('div');
|
||||||
w.innerHTML = `
|
row.className = 'whep-row';
|
||||||
<span>${escapeHTML(whepURL)}</span>
|
row.innerHTML = `
|
||||||
<a href="#" data-copy="${escapeHTML(whepURL)}">copy</a>
|
<span class="whep-label">WHEP</span>
|
||||||
<a href="${escapeHTML(playerURL)}" target="_blank" rel="noopener">open ↗</a>
|
<span class="whep-url-text">${escapeHTML(whepURL)}</span>
|
||||||
|
<span class="whep-actions">
|
||||||
|
<button class="btn btn-secondary btn-sm" data-copy="${escapeHTML(whepURL)}">Copy</button>
|
||||||
|
<a class="btn btn-secondary btn-sm" href="${escapeHTML(playerURL)}" target="_blank" rel="noopener" style="text-decoration:none">Open ↗</a>
|
||||||
|
</span>
|
||||||
`;
|
`;
|
||||||
const copyLink = w.querySelector('[data-copy]');
|
row.querySelector('[data-copy]').addEventListener('click', (e) => {
|
||||||
copyLink.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
navigator.clipboard?.writeText(whepURL).then(
|
navigator.clipboard?.writeText(whepURL).then(
|
||||||
() => log($('admin-log'), 'WHEP URL copied', 'good'),
|
() => log($('admin-log'), 'WHEP URL copied', 'good'),
|
||||||
() => log($('admin-log'), 'clipboard write failed (no permission?)', 'warn'),
|
() => log($('admin-log'), 'clipboard write failed', 'warn'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
el.appendChild(w);
|
card.appendChild(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
return el;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- toggle webrtc.enabled --------------------------------------
|
// ── toggle webrtc.enabled ─────────────────────────────────────────
|
||||||
async function toggleWebRTC(id, enabled, btn) {
|
async function toggleWebRTC(id, enabled, btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = enabled ? 'Enabling...' : 'Disabling...';
|
btn.textContent = enabled ? 'Enabling…' : 'Disabling…';
|
||||||
try {
|
try {
|
||||||
// Read the current full process config and patch only the webrtc
|
|
||||||
// block. The PUT /process/:id endpoint replaces the entire
|
|
||||||
// config, so partial payloads would silently drop other settings.
|
|
||||||
const cfg = await api('GET', '/api/v3/process/' + encodeURIComponent(id) + '/config');
|
const cfg = await api('GET', '/api/v3/process/' + encodeURIComponent(id) + '/config');
|
||||||
cfg.webrtc = cfg.webrtc || {};
|
cfg.webrtc = cfg.webrtc || {};
|
||||||
cfg.webrtc.enabled = enabled;
|
cfg.webrtc.enabled = enabled;
|
||||||
await api('PUT', '/api/v3/process/' + encodeURIComponent(id), cfg);
|
await api('PUT', '/api/v3/process/' + encodeURIComponent(id), cfg);
|
||||||
|
|
||||||
// Restart the process so the new RTP output legs take effect.
|
|
||||||
// Without this the toggle takes effect on the next manual restart
|
|
||||||
// only — surprising behaviour for a "Enable" button.
|
|
||||||
try {
|
try {
|
||||||
await api('PUT', '/api/v3/process/' + encodeURIComponent(id) + '/command', { command: 'restart' });
|
await api('PUT', '/api/v3/process/' + encodeURIComponent(id) + '/command', { command: 'restart' });
|
||||||
log($('admin-log'), `webrtc ${enabled ? 'enabled' : 'disabled'} on ${id} (restarted)`, 'good');
|
log($('admin-log'), `webrtc ${enabled ? 'enabled' : 'disabled'} on ${id} (restarted)`, 'good');
|
||||||
} catch (cmdErr) {
|
} catch (cmdErr) {
|
||||||
// Process may not have been running; the config update still applied.
|
|
||||||
log($('admin-log'), `webrtc ${enabled ? 'enabled' : 'disabled'} on ${id}, restart skipped: ${cmdErr.message}`, 'warn');
|
log($('admin-log'), `webrtc ${enabled ? 'enabled' : 'disabled'} on ${id}, restart skipped: ${cmdErr.message}`, 'warn');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh the list to pick up the new state + WHEP URL.
|
|
||||||
await refreshProcesses();
|
await refreshProcesses();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.message === 'unauthorized') return;
|
if (err.message === 'unauthorized') return;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue