1266 lines
46 KiB
Go
1266 lines
46 KiB
Go
package web
|
|
|
|
// All HTML templates and CSS are embedded as Go strings.
|
|
// Compiled into the binary — zero external file dependencies at runtime.
|
|
// As the UI grows, switch to go:embed for easier editing.
|
|
|
|
const allTemplates = layoutTmpl + dashboardTmpl + stacksTmpl + loginTmpl + logsTmpl + deployTmpl
|
|
|
|
const layoutTmpl = `
|
|
{{define "layout_start"}}
|
|
<!DOCTYPE html>
|
|
<html lang="hu">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{{.Title}}Felhom.eu</title>
|
|
<link rel="stylesheet" href="/static/style.css">
|
|
</head>
|
|
<body>
|
|
<nav class="sidebar">
|
|
<div class="sidebar-header">
|
|
<img src="/static/felhom-logo.svg" alt="Felhom.eu" class="sidebar-logo">
|
|
<span class="customer-name">{{.CustomerName}}</span>
|
|
</div>
|
|
<ul class="nav-links">
|
|
<li><a href="/" class="{{if eq .Page "dashboard"}}active{{end}}">Vezérlőpult</a></li>
|
|
<li><a href="/stacks" class="{{if eq .Page "stacks"}}active{{end}}">Alkalmazások</a></li>
|
|
</ul>
|
|
<div class="sidebar-footer">
|
|
<span class="version">v{{.Version}}</span>
|
|
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
|
</div>
|
|
</nav>
|
|
<main class="content">
|
|
{{end}}
|
|
|
|
{{define "layout_end"}}
|
|
</main>
|
|
<script>
|
|
async function stackAction(name, action) {
|
|
const btn = event.currentTarget;
|
|
const origText = btn.textContent;
|
|
btn.disabled = true;
|
|
btn.textContent = 'Folyamatban...';
|
|
btn.classList.add('loading');
|
|
try {
|
|
const resp = await fetch('/api/stacks/' + name + '/' + action, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'}
|
|
});
|
|
const data = await resp.json();
|
|
if (!data.ok) {
|
|
alert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
|
btn.textContent = origText;
|
|
btn.disabled = false;
|
|
btn.classList.remove('loading');
|
|
return;
|
|
}
|
|
window.location.reload();
|
|
} catch (err) {
|
|
alert('Hálózati hiba: ' + err.message);
|
|
btn.textContent = origText;
|
|
btn.disabled = false;
|
|
btn.classList.remove('loading');
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|
|
{{end}}
|
|
`
|
|
|
|
const dashboardTmpl = `
|
|
{{define "dashboard"}}
|
|
{{template "layout_start" .}}
|
|
|
|
<div class="page-header">
|
|
<h2>Vezérlőpult</h2>
|
|
<span class="domain-badge">{{.Domain}}</span>
|
|
</div>
|
|
|
|
<div class="stats-grid">
|
|
<div class="stat-card stat-running">
|
|
<div class="stat-value">{{.RunningCount}}</div>
|
|
<div class="stat-label">Futó alkalmazás</div>
|
|
</div>
|
|
<div class="stat-card stat-stopped">
|
|
<div class="stat-value">{{.StoppedCount}}</div>
|
|
<div class="stat-label">Leállítva</div>
|
|
</div>
|
|
<div class="stat-card stat-total">
|
|
<div class="stat-value">{{.TotalCount}}</div>
|
|
<div class="stat-label">Összes alkalmazás</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{if .SystemInfo.TotalMemMB}}
|
|
<div class="system-info-card">
|
|
<div class="system-info-items">
|
|
<div class="system-info-item">
|
|
<div class="system-info-header">
|
|
<span class="system-info-label">Memória</span>
|
|
<span class="system-info-value">{{fmtMB .SystemInfo.UsedMemMB}} / {{fmtMB .SystemInfo.TotalMemMB}} ({{printf "%.0f" .SystemInfo.MemPercent}}%)</span>
|
|
</div>
|
|
<div class="system-bar">
|
|
<div class="system-bar-fill system-bar-{{usageColor .SystemInfo.MemPercent}}" style="width:{{printf "%.0f" .SystemInfo.MemPercent}}%"></div>
|
|
</div>
|
|
</div>
|
|
<div class="system-info-item">
|
|
<div class="system-info-header">
|
|
<span class="system-info-label">SSD tárhely</span>
|
|
<span class="system-info-value">{{fmtGB .SystemInfo.DiskUsedGB}} / {{fmtGB .SystemInfo.DiskTotalGB}} ({{printf "%.0f" .SystemInfo.DiskPercent}}%)</span>
|
|
</div>
|
|
<div class="system-bar">
|
|
<div class="system-bar-fill system-bar-{{usageColor .SystemInfo.DiskPercent}}" style="width:{{printf "%.0f" .SystemInfo.DiskPercent}}%"></div>
|
|
</div>
|
|
</div>
|
|
{{if .SystemInfo.HDDConfigured}}
|
|
<div class="system-info-item">
|
|
<div class="system-info-header">
|
|
<span class="system-info-label">Külső HDD</span>
|
|
<span class="system-info-value">{{fmtGB .SystemInfo.HDDUsedGB}} / {{fmtGB .SystemInfo.HDDTotalGB}} ({{printf "%.0f" .SystemInfo.HDDPercent}}%)</span>
|
|
</div>
|
|
<div class="system-bar">
|
|
<div class="system-bar-fill system-bar-{{usageColor .SystemInfo.HDDPercent}}" style="width:{{printf "%.0f" .SystemInfo.HDDPercent}}%"></div>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
|
|
<h3>Alkalmazások állapota</h3>
|
|
|
|
<div class="stack-list">
|
|
{{range .Stacks}}
|
|
<div class="stack-card stack-state-{{stateColor .State}}">
|
|
<div class="stack-info">
|
|
<img class="stack-logo" src="{{logoURL .Meta.Slug}}"
|
|
alt="{{.Meta.DisplayName}}" onerror="this.onerror=function(){this.style.display='none'};this.src='{{logoPNGURL .Meta.Slug}}'">
|
|
<div>
|
|
<strong class="stack-name">{{.Meta.DisplayName}}</strong>
|
|
{{if .Meta.Description}}<span class="stack-desc">{{.Meta.Description}}</span>{{end}}
|
|
</div>
|
|
</div>
|
|
<div class="stack-actions">
|
|
<span class="stack-state-label">{{stateLabel .State}}</span>
|
|
|
|
{{if .Protected}}
|
|
<span class="badge badge-protected">Védett</span>
|
|
{{else if not .Deployed}}
|
|
<a href="/stacks/{{.Name}}/deploy" class="btn btn-sm btn-primary">Telepítés</a>
|
|
{{else}}
|
|
{{if isOperational .State}}
|
|
<button class="btn btn-sm btn-warning" onclick="stackAction('{{.Name}}', 'restart')">↻</button>
|
|
<button class="btn btn-sm btn-danger" onclick="stackAction('{{.Name}}', 'stop')">■</button>
|
|
{{else}}
|
|
<button class="btn btn-sm btn-success" onclick="stackAction('{{.Name}}', 'start')">▶</button>
|
|
{{end}}
|
|
<a href="/stacks/{{.Name}}/logs" class="btn btn-sm btn-outline">Napló</a>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
{{else}}
|
|
<div class="empty-state">
|
|
<p>Nincs elérhető alkalmazás.</p>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
|
|
{{template "layout_end" .}}
|
|
{{end}}
|
|
`
|
|
|
|
const stacksTmpl = `
|
|
{{define "stacks"}}
|
|
{{template "layout_start" .}}
|
|
|
|
<div class="page-header">
|
|
<h2>Alkalmazások</h2>
|
|
<span class="domain-badge">{{.Domain}}</span>
|
|
</div>
|
|
|
|
<div class="stack-grid">
|
|
{{range .Stacks}}
|
|
<div class="stack-detail-card stack-state-{{stateColor .State}}">
|
|
<div class="stack-detail-header">
|
|
<div class="stack-title-row">
|
|
<img class="stack-logo-lg" src="{{logoURL .Meta.Slug}}"
|
|
alt="" onerror="this.onerror=function(){this.style.display='none'};this.src='{{logoPNGURL .Meta.Slug}}'">
|
|
<div>
|
|
<h3>{{.Meta.DisplayName}}</h3>
|
|
{{if .Meta.Subdomain}}
|
|
<a class="subdomain-link" href="https://{{.Meta.Subdomain}}.{{$.Domain}}" target="_blank">
|
|
{{.Meta.Subdomain}}.{{$.Domain}} ↗
|
|
</a>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
<span class="stack-state-badge state-{{stateColor .State}}">{{stateLabel .State}}</span>
|
|
</div>
|
|
|
|
{{if .Meta.Description}}
|
|
<p class="stack-detail-desc">{{.Meta.Description}}</p>
|
|
{{end}}
|
|
|
|
<div class="stack-meta-badges">
|
|
{{if .Meta.Resources.MemRequest}}<span class="meta-badge">~{{.Meta.Resources.MemRequest}}</span>{{end}}
|
|
{{if .Meta.Resources.PiCompatible}}<span class="meta-badge meta-badge-ok">Pi kompatibilis</span>{{end}}
|
|
{{if .Meta.Resources.NeedsHDD}}<span class="meta-badge">HDD szükséges</span>{{end}}
|
|
</div>
|
|
|
|
{{if .Containers}}
|
|
<div class="container-list">
|
|
{{range .Containers}}
|
|
<div class="container-row">
|
|
<span class="container-name">{{.Name}}</span>
|
|
<span class="container-status state-text-{{stateColor .State}}">{{.Status}}</span>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
{{end}}
|
|
|
|
<div class="stack-detail-actions">
|
|
{{if .Protected}}
|
|
<span class="badge badge-protected">Védett rendszerkomponens</span>
|
|
{{else if not .Deployed}}
|
|
<a href="/stacks/{{.Name}}/deploy" class="btn btn-primary">Telepítés</a>
|
|
<a href="{{appPageURL .Meta.Slug}}" class="btn btn-outline">Részletek</a>
|
|
{{else}}
|
|
{{if isOperational .State}}
|
|
<button class="btn btn-success" onclick="stackAction('{{.Name}}', 'update')">Frissítés</button>
|
|
<button class="btn btn-warning" onclick="stackAction('{{.Name}}', 'restart')">Újraindítás</button>
|
|
<button class="btn btn-danger" onclick="stackAction('{{.Name}}', 'stop')">Leállítás</button>
|
|
{{else}}
|
|
<button class="btn btn-success" onclick="stackAction('{{.Name}}', 'start')">Indítás</button>
|
|
{{end}}
|
|
<a href="/stacks/{{.Name}}/logs" class="btn btn-outline">Naplók</a>
|
|
<a href="{{appPageURL .Meta.Slug}}" class="btn btn-outline">Részletek</a>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
|
|
{{template "layout_end" .}}
|
|
{{end}}
|
|
`
|
|
|
|
const deployTmpl = `
|
|
{{define "deploy"}}
|
|
{{template "layout_start" .}}
|
|
|
|
<div class="page-header">
|
|
<a href="/stacks" class="btn btn-sm btn-outline">← Vissza</a>
|
|
<h2>{{.Meta.DisplayName}} — Telepítés</h2>
|
|
</div>
|
|
|
|
<div class="deploy-container">
|
|
<div class="deploy-info">
|
|
<img class="deploy-logo" src="{{.LogoURL}}" alt="" onerror="this.onerror=function(){this.style.display='none'};this.src='{{.LogoPNGURL}}'">
|
|
<div>
|
|
<h3>{{.Meta.DisplayName}}</h3>
|
|
{{if .Meta.Description}}<p>{{.Meta.Description}}</p>{{end}}
|
|
<div class="stack-meta-badges">
|
|
{{if .Meta.Resources.MemRequest}}<span class="meta-badge">~{{.Meta.Resources.MemRequest}}</span>{{end}}
|
|
{{if .Meta.Resources.PiCompatible}}<span class="meta-badge meta-badge-ok">Pi kompatibilis</span>{{end}}
|
|
{{if .Meta.Resources.NeedsHDD}}<span class="meta-badge">HDD szükséges</span>{{end}}
|
|
</div>
|
|
<a href="{{.AppPageURL}}" target="_blank" class="btn btn-sm btn-outline" style="margin-top:0.5rem">
|
|
Részletes leírás, képernyőképek
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
{{if .AlreadyDeployed}}
|
|
<div class="alert alert-info">
|
|
Ez az alkalmazás már telepítve van. Az alábbi beállítások csak olvashatók.
|
|
</div>
|
|
{{end}}
|
|
|
|
{{if and (not .AlreadyDeployed) .MemoryInfo}}
|
|
{{with .MemoryInfo}}
|
|
{{if .Available}}
|
|
<div class="memory-summary{{if .Blocked}} memory-blocked{{end}}">
|
|
{{if .Blocked}}
|
|
<div class="alert alert-error" style="margin-bottom:0">
|
|
Nincs elég memória! Foglalás telepítés után: {{.AfterMB}} MB / {{.UsableMB}} MB
|
|
</div>
|
|
{{else}}
|
|
<div class="memory-summary-header">
|
|
<span class="memory-summary-label">Memória foglalás</span>
|
|
<span class="memory-summary-value">{{.AfterMB}} MB / {{.UsableMB}} MB ({{.Percent}}%)</span>
|
|
</div>
|
|
<div class="system-bar" style="margin-bottom:0">
|
|
<div class="system-bar-fill system-bar-{{if ge .Percent 85}}red{{else if ge .Percent 70}}yellow{{else}}green{{end}}" style="width:{{.Percent}}%"></div>
|
|
</div>
|
|
{{if .OvercommitWarn}}
|
|
<div class="alert alert-warning" style="margin-top:0.5rem;margin-bottom:0">
|
|
Az alkalmazások csúcsterhelése meghaladhatja a rendelkezésre álló memóriát.
|
|
Normál használat mellett ez nem okoz problémát.
|
|
</div>
|
|
{{end}}
|
|
{{end}}
|
|
</div>
|
|
{{end}}
|
|
{{end}}
|
|
{{end}}
|
|
|
|
<form id="deploy-form" class="deploy-form">
|
|
{{if .AutoFields}}
|
|
<div class="form-section">
|
|
<h4>Automatikusan generált értékek</h4>
|
|
<p class="form-section-desc">Ezek az értékek automatikusan létrejönnek a telepítéskor.</p>
|
|
{{range .AutoFields}}
|
|
<div class="form-group form-group-auto">
|
|
<label>{{.Label}}</label>
|
|
<span class="auto-generated-badge">✓ Automatikusan generálva</span>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
{{end}}
|
|
|
|
{{if .UserFields}}
|
|
<div class="form-section">
|
|
<h4>Beállítások</h4>
|
|
{{range .UserFields}}
|
|
<div class="form-group">
|
|
<label for="field-{{.EnvVar}}">
|
|
{{.Label}}
|
|
{{if or .Required (eq .Type "password")}}<span class="required">*</span>{{end}}
|
|
{{if .LockedAfterDeploy}}<span class="locked-hint">telepítés után nem módosítható</span>{{end}}
|
|
</label>
|
|
|
|
{{if eq .Type "select"}}
|
|
<select id="field-{{.EnvVar}}" name="{{.EnvVar}}" class="form-control"
|
|
{{if $.AlreadyDeployed}}disabled{{end}}>
|
|
{{range .Options}}
|
|
<option value="{{.Value}}">{{.Label}}</option>
|
|
{{end}}
|
|
</select>
|
|
{{else if eq .Type "password"}}
|
|
<div class="input-with-button">
|
|
<input type="text" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
|
|
class="form-control" value="{{.Default}}"
|
|
placeholder="{{.Placeholder}}"
|
|
data-field-type="password"
|
|
required
|
|
{{if $.AlreadyDeployed}}disabled{{end}}>
|
|
<button type="button" class="btn btn-sm btn-outline"
|
|
onclick="generatePassword('field-{{.EnvVar}}')">Generálás</button>
|
|
</div>
|
|
{{else if eq .Type "boolean"}}
|
|
<label class="toggle">
|
|
<input type="checkbox" id="field-{{.EnvVar}}" name="{{.EnvVar}}" value="true"
|
|
{{if $.AlreadyDeployed}}disabled{{end}}>
|
|
<span class="toggle-label">Igen</span>
|
|
</label>
|
|
{{else}}
|
|
<input type="text" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
|
|
class="form-control" value="{{.Default}}"
|
|
placeholder="{{.Placeholder}}"
|
|
{{if .Required}}required{{end}}
|
|
{{if $.AlreadyDeployed}}disabled{{end}}>
|
|
{{end}}
|
|
|
|
{{if .Description}}
|
|
<span class="form-hint">{{.Description}}</span>
|
|
{{end}}
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
{{end}}
|
|
|
|
{{if not .AlreadyDeployed}}
|
|
<div class="deploy-actions">
|
|
<button type="submit" class="btn btn-primary btn-lg"{{if and .MemoryInfo (index .MemoryInfo "Blocked")}} disabled title="Nincs elég memória"{{end}}>Telepítés indítása</button>
|
|
<a href="/stacks" class="btn btn-outline">Mégsem</a>
|
|
</div>
|
|
{{end}}
|
|
</form>
|
|
</div>
|
|
|
|
<script>
|
|
function generatePassword(fieldId) {
|
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
let pass = '';
|
|
const arr = new Uint8Array(16);
|
|
crypto.getRandomValues(arr);
|
|
for (let i = 0; i < 16; i++) {
|
|
pass += chars[arr[i] % chars.length];
|
|
}
|
|
document.getElementById(fieldId).value = pass;
|
|
}
|
|
|
|
document.getElementById('deploy-form').addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
|
|
// Client-side validation: check all password fields are filled
|
|
const passwordFields = e.target.querySelectorAll('input[data-field-type="password"]');
|
|
for (const pf of passwordFields) {
|
|
if (!pf.disabled && pf.value.trim() === '') {
|
|
const label = pf.closest('.form-group').querySelector('label').textContent.trim();
|
|
alert('Kötelező mező: ' + label + '\nHasználja a Generálás gombot vagy írjon be egy jelszót.');
|
|
pf.focus();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Client-side validation: check all required fields
|
|
const requiredFields = e.target.querySelectorAll('input[required], select[required]');
|
|
for (const rf of requiredFields) {
|
|
if (!rf.disabled && rf.value.trim() === '') {
|
|
const label = rf.closest('.form-group').querySelector('label').textContent.trim();
|
|
alert('Kötelező mező: ' + label);
|
|
rf.focus();
|
|
return;
|
|
}
|
|
}
|
|
|
|
const btn = e.target.querySelector('[type=submit]');
|
|
const origText = btn.textContent;
|
|
btn.disabled = true;
|
|
btn.textContent = 'Telepítés folyamatban...';
|
|
|
|
const values = {};
|
|
const inputs = e.target.querySelectorAll('input, select');
|
|
inputs.forEach(function(el) {
|
|
if (el.name && !el.disabled) {
|
|
if (el.type === 'checkbox') {
|
|
values[el.name] = el.checked ? 'true' : 'false';
|
|
} else {
|
|
values[el.name] = el.value;
|
|
}
|
|
}
|
|
});
|
|
|
|
try {
|
|
const resp = await fetch('/api/stacks/{{.Stack.Name}}/deploy', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({values: values})
|
|
});
|
|
const data = await resp.json();
|
|
if (!data.ok) {
|
|
alert('Hiba: ' + data.error);
|
|
btn.textContent = origText;
|
|
btn.disabled = false;
|
|
return;
|
|
}
|
|
if (data.data && data.data.warning) {
|
|
alert('Sikeres telepítés!\n\nFigyelmeztetés: ' + data.data.warning);
|
|
} else {
|
|
alert('Sikeres telepítés!');
|
|
}
|
|
window.location.href = '/stacks';
|
|
} catch (err) {
|
|
alert('Hálózati hiba: ' + err.message);
|
|
btn.textContent = origText;
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
</script>
|
|
|
|
{{template "layout_end" .}}
|
|
{{end}}
|
|
`
|
|
|
|
const loginTmpl = `
|
|
{{define "login"}}
|
|
<!DOCTYPE html>
|
|
<html lang="hu">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Bejelentkezés — Felhom</title>
|
|
<link rel="stylesheet" href="/static/style.css">
|
|
</head>
|
|
<body class="login-body">
|
|
<div class="login-card">
|
|
<img src="/static/felhom-logo.svg" alt="Felhom.eu" class="login-logo">
|
|
<p class="login-subtitle">{{.CustomerName}}</p>
|
|
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
|
|
<form method="POST" action="/login">
|
|
<div class="form-group">
|
|
<label for="password">Jelszó</label>
|
|
<input type="password" id="password" name="password" required autofocus
|
|
placeholder="Adja meg a jelszavát" class="form-control">
|
|
</div>
|
|
<button type="submit" class="btn btn-primary btn-full">Bejelentkezés</button>
|
|
</form>
|
|
<p class="login-footer">Felhom — Otthoni szerver kezelés<br>
|
|
<a href="https://felhom.eu" target="_blank">felhom.eu</a></p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
{{end}}
|
|
`
|
|
|
|
const logsTmpl = `
|
|
{{define "logs"}}
|
|
{{template "layout_start" .}}
|
|
<div class="page-header">
|
|
<a href="/stacks" class="btn btn-sm btn-outline">← Vissza</a>
|
|
<h2>{{.Stack.Meta.DisplayName}} — Naplók</h2>
|
|
</div>
|
|
<div class="logs-container">
|
|
<pre class="logs-output">{{.Logs}}</pre>
|
|
</div>
|
|
<div class="logs-actions">
|
|
<button class="btn btn-outline" onclick="window.location.reload()">Frissítés</button>
|
|
</div>
|
|
{{template "layout_end" .}}
|
|
{{end}}
|
|
`
|
|
|
|
// CSS is defined in a separate const for readability.
|
|
// Served at /static/style.css
|
|
const cssContent = `
|
|
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
|
|
|
:root {
|
|
--bg-primary: #0d1117;
|
|
--bg-secondary: #161b22;
|
|
--bg-card: #1c2128;
|
|
--text-primary: #e6edf3;
|
|
--text-secondary: #8b949e;
|
|
--text-muted: #6e7681;
|
|
--accent-blue: #0088cc;
|
|
--accent-light: #00aaff;
|
|
--accent-glow: rgba(0, 136, 204, 0.3);
|
|
--border-color: #30363d;
|
|
--green: #238636;
|
|
--green-bg: rgba(35, 134, 54, 0.15);
|
|
--red: #da3633;
|
|
--red-bg: rgba(218, 54, 51, 0.15);
|
|
--yellow: #d29922;
|
|
--yellow-bg: rgba(210, 153, 34, 0.15);
|
|
--orange: #db6d28;
|
|
--orange-bg: rgba(219, 109, 40, 0.15);
|
|
--gray: #6e7681;
|
|
--gray-bg: rgba(110, 118, 129, 0.15);
|
|
--radius: 12px;
|
|
--shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
html { scroll-behavior: smooth; }
|
|
|
|
body {
|
|
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
display: flex;
|
|
min-height: 100vh;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
body::before {
|
|
content: '';
|
|
position: fixed;
|
|
top: 0; left: 0;
|
|
width: 100%; height: 100%;
|
|
background-image:
|
|
linear-gradient(rgba(0, 136, 204, 0.03) 1px, transparent 1px),
|
|
linear-gradient(90deg, rgba(0, 136, 204, 0.03) 1px, transparent 1px);
|
|
background-size: 50px 50px;
|
|
pointer-events: none;
|
|
z-index: -1;
|
|
}
|
|
|
|
/* Sidebar */
|
|
.sidebar {
|
|
width: 240px;
|
|
background: var(--bg-secondary);
|
|
color: var(--text-primary);
|
|
display: flex;
|
|
flex-direction: column;
|
|
position: fixed;
|
|
height: 100vh;
|
|
overflow-y: auto;
|
|
border-right: 1px solid var(--border-color);
|
|
}
|
|
.sidebar-header {
|
|
padding: 1.5rem;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
.sidebar-logo {
|
|
width: 140px;
|
|
height: auto;
|
|
display: block;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
.login-logo {
|
|
width: 200px;
|
|
height: auto;
|
|
display: block;
|
|
margin: 0 auto 0.5rem;
|
|
}
|
|
.customer-name {
|
|
display: block;
|
|
font-size: .85rem;
|
|
color: var(--text-secondary);
|
|
margin-top: .25rem;
|
|
}
|
|
.nav-links {
|
|
list-style: none;
|
|
padding: 1rem 0;
|
|
flex: 1;
|
|
}
|
|
.nav-links a {
|
|
display: block;
|
|
padding: .75rem 1.5rem;
|
|
color: var(--text-secondary);
|
|
text-decoration: none;
|
|
font-size: .95rem;
|
|
font-weight: 500;
|
|
transition: color 0.2s ease, background 0.2s ease;
|
|
}
|
|
.nav-links a:hover {
|
|
color: var(--accent-light);
|
|
background: rgba(0, 136, 204, 0.08);
|
|
}
|
|
.nav-links a.active {
|
|
color: var(--accent-light);
|
|
background: rgba(0, 136, 204, 0.12);
|
|
border-left: 3px solid var(--accent-blue);
|
|
}
|
|
.sidebar-footer {
|
|
padding: 1rem 1.5rem;
|
|
border-top: 1px solid var(--border-color);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
font-size: .8rem;
|
|
}
|
|
.version { color: var(--text-muted); }
|
|
.logout-link { color: var(--text-muted); text-decoration: none; transition: color 0.2s ease; }
|
|
.logout-link:hover { color: var(--accent-light); }
|
|
|
|
/* Main content */
|
|
.content {
|
|
margin-left: 240px;
|
|
padding: 2rem;
|
|
flex: 1;
|
|
max-width: 1200px;
|
|
}
|
|
.page-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
.page-header h2 {
|
|
font-size: 1.5rem;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
}
|
|
.domain-badge {
|
|
background: rgba(0, 136, 204, 0.15);
|
|
color: var(--accent-light);
|
|
padding: .25rem .75rem;
|
|
border-radius: 999px;
|
|
font-size: .8rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
h3 {
|
|
font-size: 1.1rem;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
/* Stats grid */
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
gap: 1rem;
|
|
margin-bottom: 2rem;
|
|
}
|
|
.stat-card {
|
|
background: var(--bg-card);
|
|
border-radius: var(--radius);
|
|
padding: 1.25rem;
|
|
border: 1px solid var(--border-color);
|
|
border-left: 4px solid var(--gray);
|
|
transition: border-color 0.3s ease;
|
|
}
|
|
.stat-running { border-left-color: var(--green); }
|
|
.stat-stopped { border-left-color: var(--red); }
|
|
.stat-total { border-left-color: var(--accent-blue); }
|
|
.stat-value {
|
|
font-size: 2rem;
|
|
font-weight: 700;
|
|
background: linear-gradient(135deg, var(--accent-light), var(--accent-blue));
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
background-clip: text;
|
|
}
|
|
.stat-running .stat-value { background: var(--green); -webkit-background-clip: text; background-clip: text; }
|
|
.stat-stopped .stat-value { background: var(--red); -webkit-background-clip: text; background-clip: text; }
|
|
.stat-label {
|
|
color: var(--text-secondary);
|
|
font-size: .85rem;
|
|
margin-top: .25rem;
|
|
}
|
|
|
|
/* System info bar */
|
|
.system-info-card {
|
|
background: var(--bg-card);
|
|
border-radius: var(--radius);
|
|
padding: 1rem 1.25rem;
|
|
border: 1px solid var(--border-color);
|
|
margin-bottom: 2rem;
|
|
}
|
|
.system-info-items {
|
|
display: flex;
|
|
gap: 2rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.system-info-item {
|
|
flex: 1;
|
|
min-width: 200px;
|
|
}
|
|
.system-info-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: .5rem;
|
|
}
|
|
.system-info-label {
|
|
font-size: .8rem;
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: .5px;
|
|
}
|
|
.system-info-value {
|
|
font-size: .8rem;
|
|
color: var(--text-muted);
|
|
font-family: 'JetBrains Mono', monospace;
|
|
}
|
|
.system-bar {
|
|
width: 100%;
|
|
height: 10px;
|
|
border-radius: 5px;
|
|
position: relative;
|
|
background: linear-gradient(to right,
|
|
rgba(35, 134, 54, 0.10) 0%, rgba(35, 134, 54, 0.10) 70%,
|
|
rgba(210, 153, 34, 0.18) 70%, rgba(210, 153, 34, 0.18) 85%,
|
|
rgba(218, 54, 51, 0.18) 85%, rgba(218, 54, 51, 0.18) 100%
|
|
);
|
|
}
|
|
.system-bar::before {
|
|
content: '';
|
|
position: absolute;
|
|
left: 70%;
|
|
top: -1px;
|
|
bottom: -1px;
|
|
width: 2px;
|
|
background: var(--yellow);
|
|
border-radius: 1px;
|
|
opacity: 0.5;
|
|
z-index: 2;
|
|
}
|
|
.system-bar::after {
|
|
content: '';
|
|
position: absolute;
|
|
left: 85%;
|
|
top: -1px;
|
|
bottom: -1px;
|
|
width: 2px;
|
|
background: var(--red);
|
|
border-radius: 1px;
|
|
opacity: 0.5;
|
|
z-index: 2;
|
|
}
|
|
.system-bar-fill {
|
|
height: 100%;
|
|
border-radius: 5px;
|
|
position: relative;
|
|
z-index: 1;
|
|
transition: width 0.3s ease;
|
|
min-width: 3px;
|
|
}
|
|
.system-bar-green { background: var(--green); box-shadow: 0 0 6px rgba(35, 134, 54, 0.4); }
|
|
.system-bar-yellow { background: var(--yellow); box-shadow: 0 0 6px rgba(210, 153, 34, 0.4); }
|
|
.system-bar-red { background: var(--red); box-shadow: 0 0 6px rgba(218, 54, 51, 0.4); }
|
|
|
|
/* Stack list (dashboard) */
|
|
.stack-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: .5rem;
|
|
}
|
|
.stack-card {
|
|
background: var(--bg-card);
|
|
border-radius: var(--radius);
|
|
padding: 1rem 1.25rem;
|
|
border: 1px solid var(--border-color);
|
|
border-left: 4px solid var(--gray);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
transition: border-color 0.3s ease, transform 0.2s ease;
|
|
}
|
|
.stack-card:hover {
|
|
border-color: var(--accent-blue);
|
|
}
|
|
.stack-state-green { border-left-color: var(--green); }
|
|
.stack-state-red { border-left-color: var(--red); }
|
|
.stack-state-yellow { border-left-color: var(--yellow); }
|
|
.stack-state-orange { border-left-color: var(--orange); }
|
|
.stack-state-gray { border-left-color: var(--gray); }
|
|
.stack-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: .75rem;
|
|
}
|
|
.stack-logo {
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: 6px;
|
|
object-fit: contain;
|
|
background: var(--bg-secondary);
|
|
padding: 4px;
|
|
}
|
|
.stack-logo-lg {
|
|
width: 48px;
|
|
height: 48px;
|
|
border-radius: 8px;
|
|
object-fit: contain;
|
|
background: var(--bg-secondary);
|
|
padding: 6px;
|
|
}
|
|
.stack-name { font-size: 1rem; font-weight: 500; }
|
|
.stack-desc { display: block; font-size: .8rem; color: var(--text-secondary); }
|
|
.stack-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: .5rem;
|
|
}
|
|
.stack-state-label {
|
|
font-size: .8rem;
|
|
color: var(--text-secondary);
|
|
margin-right: .5rem;
|
|
}
|
|
|
|
/* Stack detail grid (applications page) */
|
|
.stack-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
|
gap: 1rem;
|
|
}
|
|
.stack-detail-card {
|
|
background: var(--bg-card);
|
|
border-radius: var(--radius);
|
|
padding: 1.25rem;
|
|
border: 1px solid var(--border-color);
|
|
border-top: 4px solid var(--gray);
|
|
transition: border-color 0.3s ease, transform 0.3s ease;
|
|
}
|
|
.stack-detail-card:hover {
|
|
border-color: var(--accent-blue);
|
|
transform: translateY(-2px);
|
|
}
|
|
.stack-detail-card.stack-state-green { border-top-color: var(--green); }
|
|
.stack-detail-card.stack-state-red { border-top-color: var(--red); }
|
|
.stack-detail-card.stack-state-orange { border-top-color: var(--orange); }
|
|
.stack-detail-card.stack-state-yellow { border-top-color: var(--yellow); }
|
|
.stack-detail-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
margin-bottom: .75rem;
|
|
}
|
|
.stack-title-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: .75rem;
|
|
}
|
|
.subdomain-link {
|
|
font-size: .8rem;
|
|
color: var(--accent-light);
|
|
text-decoration: none;
|
|
transition: color 0.2s ease;
|
|
}
|
|
.subdomain-link:hover { text-decoration: underline; }
|
|
.stack-state-badge {
|
|
padding: .2rem .6rem;
|
|
border-radius: 999px;
|
|
font-size: .75rem;
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
}
|
|
.state-green { background: var(--green-bg); color: var(--green); }
|
|
.state-red { background: var(--red-bg); color: var(--red); }
|
|
.state-yellow { background: var(--yellow-bg); color: var(--yellow); }
|
|
.state-orange { background: var(--orange-bg); color: var(--orange); }
|
|
.state-gray { background: var(--gray-bg); color: var(--gray); }
|
|
.state-text-green { color: var(--green); }
|
|
.state-text-red { color: var(--red); }
|
|
.state-text-orange { color: var(--orange); }
|
|
.state-text-yellow { color: var(--yellow); }
|
|
.stack-detail-desc {
|
|
color: var(--text-secondary);
|
|
font-size: .85rem;
|
|
margin-bottom: .75rem;
|
|
}
|
|
.stack-meta-badges {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: .4rem;
|
|
margin: .5rem 0;
|
|
}
|
|
.meta-badge {
|
|
background: rgba(0, 136, 204, 0.1);
|
|
color: var(--text-secondary);
|
|
padding: .15rem .5rem;
|
|
border-radius: 6px;
|
|
font-size: .75rem;
|
|
}
|
|
.meta-badge-ok {
|
|
background: var(--green-bg);
|
|
color: var(--green);
|
|
}
|
|
.container-list { margin: .75rem 0; }
|
|
.container-list h4 { font-size: .8rem; color: var(--text-muted); margin-bottom: .4rem; }
|
|
.container-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: .8rem;
|
|
padding: .3rem 0;
|
|
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
|
|
}
|
|
.container-row:last-child { border-bottom: none; }
|
|
.container-name { font-family: 'JetBrains Mono', monospace; color: var(--text-secondary); }
|
|
.container-status { font-size: .75rem; }
|
|
.stack-detail-actions {
|
|
display: flex;
|
|
gap: .5rem;
|
|
margin-top: 1rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
/* Buttons */
|
|
.btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: .3rem;
|
|
padding: .5rem 1rem;
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-size: .85rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
text-decoration: none;
|
|
color: #fff;
|
|
font-family: inherit;
|
|
}
|
|
.btn:hover { transform: translateY(-1px); }
|
|
.btn:active { transform: scale(.97); }
|
|
.btn:disabled { opacity: .5; cursor: not-allowed; transform: none; }
|
|
.btn.loading { opacity: .6; }
|
|
.btn-sm { padding: .3rem .6rem; font-size: .8rem; }
|
|
.btn-lg { padding: .65rem 1.5rem; font-size: 1rem; }
|
|
.btn-full { width: 100%; justify-content: center; }
|
|
.btn-primary {
|
|
background: linear-gradient(135deg, var(--accent-blue), var(--accent-light));
|
|
box-shadow: 0 4px 12px var(--accent-glow);
|
|
}
|
|
.btn-primary:hover { box-shadow: 0 6px 20px var(--accent-glow); }
|
|
.btn-success { background: var(--green); }
|
|
.btn-success:hover { box-shadow: 0 4px 12px rgba(35, 134, 54, 0.3); }
|
|
.btn-warning { background: var(--yellow); color: #0d1117; }
|
|
.btn-danger { background: var(--red); }
|
|
.btn-danger:hover { box-shadow: 0 4px 12px rgba(218, 54, 51, 0.3); }
|
|
.btn-outline {
|
|
background: transparent;
|
|
border: 1px solid var(--border-color);
|
|
color: var(--text-secondary);
|
|
}
|
|
.btn-outline:hover {
|
|
border-color: var(--accent-blue);
|
|
color: var(--accent-light);
|
|
background: rgba(0, 136, 204, 0.08);
|
|
}
|
|
.badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: .25rem;
|
|
padding: .2rem .6rem;
|
|
border-radius: 999px;
|
|
font-size: .75rem;
|
|
font-weight: 500;
|
|
}
|
|
.badge-protected {
|
|
background: var(--gray-bg);
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
/* Deploy page */
|
|
.deploy-container { max-width: 700px; }
|
|
.deploy-info {
|
|
display: flex;
|
|
gap: 1rem;
|
|
align-items: flex-start;
|
|
background: var(--bg-card);
|
|
padding: 1.25rem;
|
|
border-radius: var(--radius);
|
|
border: 1px solid var(--border-color);
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
.deploy-logo {
|
|
width: 64px;
|
|
height: 64px;
|
|
border-radius: 12px;
|
|
object-fit: contain;
|
|
background: var(--bg-secondary);
|
|
padding: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
.deploy-info h3 { font-size: 1.2rem; margin-bottom: .25rem; }
|
|
.deploy-info p { color: var(--text-secondary); font-size: .9rem; }
|
|
.deploy-form {
|
|
background: var(--bg-card);
|
|
padding: 1.5rem;
|
|
border-radius: var(--radius);
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
.form-section { margin-bottom: 1.5rem; }
|
|
.form-section h4 { font-size: 1rem; margin-bottom: .5rem; color: var(--text-primary); }
|
|
.form-section-desc { color: var(--text-secondary); font-size: .85rem; margin-bottom: .75rem; }
|
|
.form-group { margin-bottom: 1rem; }
|
|
.form-group label { display: block; font-size: .85rem; font-weight: 500; margin-bottom: .4rem; color: var(--text-primary); }
|
|
.form-group-auto {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: .5rem .75rem;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
}
|
|
.form-group-auto label { margin: 0; }
|
|
.auto-generated-badge { color: var(--green); font-size: .8rem; font-weight: 500; }
|
|
.form-control {
|
|
width: 100%;
|
|
padding: .55rem .75rem;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
font-size: .9rem;
|
|
background: var(--bg-secondary);
|
|
color: var(--text-primary);
|
|
font-family: inherit;
|
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
|
}
|
|
.form-control:focus {
|
|
outline: none;
|
|
border-color: var(--accent-blue);
|
|
box-shadow: 0 0 0 3px var(--accent-glow);
|
|
}
|
|
.form-control:disabled {
|
|
background: var(--bg-primary);
|
|
color: var(--text-muted);
|
|
cursor: not-allowed;
|
|
}
|
|
select.form-control { appearance: auto; }
|
|
select.form-control option { background: var(--bg-secondary); color: var(--text-primary); }
|
|
.input-with-button { display: flex; gap: .5rem; }
|
|
.input-with-button .form-control { flex: 1; }
|
|
.form-hint { display: block; font-size: .8rem; color: var(--text-muted); margin-top: .25rem; }
|
|
.required { color: var(--red); }
|
|
.locked-hint { font-size: .75rem; color: var(--text-muted); font-weight: 400; margin-left: .5rem; }
|
|
.deploy-actions {
|
|
display: flex;
|
|
gap: .75rem;
|
|
margin-top: 1.5rem;
|
|
padding-top: 1rem;
|
|
border-top: 1px solid var(--border-color);
|
|
}
|
|
|
|
/* Toggle switch */
|
|
.toggle { cursor: pointer; display: flex; align-items: center; gap: .5rem; }
|
|
.toggle input[type="checkbox"] { accent-color: var(--accent-blue); }
|
|
.toggle-label { font-size: .9rem; color: var(--text-secondary); }
|
|
|
|
/* Alerts */
|
|
.alert {
|
|
padding: .75rem 1rem;
|
|
border-radius: 8px;
|
|
margin-bottom: 1rem;
|
|
font-size: .85rem;
|
|
border: 1px solid;
|
|
}
|
|
.alert-error {
|
|
background: var(--red-bg);
|
|
color: var(--red);
|
|
border-color: rgba(218, 54, 51, 0.3);
|
|
}
|
|
.alert-info {
|
|
background: rgba(0, 136, 204, 0.1);
|
|
color: var(--accent-light);
|
|
border-color: rgba(0, 136, 204, 0.3);
|
|
}
|
|
.alert-warning {
|
|
background: var(--yellow-bg);
|
|
color: var(--yellow);
|
|
border-color: rgba(210, 153, 34, 0.3);
|
|
}
|
|
|
|
/* Memory summary on deploy page */
|
|
.memory-summary {
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius);
|
|
padding: 1rem 1.25rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.memory-blocked {
|
|
border-color: rgba(218, 54, 51, 0.5);
|
|
}
|
|
.memory-summary-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.memory-summary-label {
|
|
font-size: .8rem;
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: .5px;
|
|
}
|
|
.memory-summary-value {
|
|
font-size: .8rem;
|
|
color: var(--text-muted);
|
|
font-family: 'JetBrains Mono', monospace;
|
|
}
|
|
|
|
/* Logs */
|
|
.logs-container {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius);
|
|
padding: 1rem;
|
|
overflow-x: auto;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.logs-output {
|
|
color: var(--text-primary);
|
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
font-size: .8rem;
|
|
line-height: 1.5;
|
|
white-space: pre-wrap;
|
|
word-break: break-all;
|
|
}
|
|
.logs-actions { display: flex; gap: .5rem; }
|
|
|
|
.empty-state { text-align: center; padding: 3rem; color: var(--text-muted); }
|
|
|
|
/* Login page */
|
|
.login-body {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
min-height: 100vh;
|
|
background: var(--bg-primary);
|
|
}
|
|
.login-body::before {
|
|
content: '';
|
|
position: fixed;
|
|
top: 0; left: 0;
|
|
width: 100%; height: 100%;
|
|
background-image:
|
|
linear-gradient(rgba(0, 136, 204, 0.03) 1px, transparent 1px),
|
|
linear-gradient(90deg, rgba(0, 136, 204, 0.03) 1px, transparent 1px);
|
|
background-size: 50px 50px;
|
|
pointer-events: none;
|
|
z-index: 0;
|
|
}
|
|
.login-card {
|
|
background: var(--bg-card);
|
|
padding: 2.5rem;
|
|
border-radius: 16px;
|
|
border: 1px solid var(--border-color);
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
|
width: 100%;
|
|
max-width: 380px;
|
|
text-align: center;
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
.login-card .login-logo {
|
|
width: 220px;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.login-subtitle {
|
|
color: var(--text-secondary);
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
.login-footer {
|
|
margin-top: 1.5rem;
|
|
font-size: .75rem;
|
|
color: var(--text-muted);
|
|
}
|
|
.login-footer a {
|
|
color: var(--accent-light);
|
|
text-decoration: none;
|
|
transition: color 0.2s ease;
|
|
}
|
|
.login-footer a:hover { color: var(--accent-blue); }
|
|
|
|
/* Responsive */
|
|
@media(max-width: 768px) {
|
|
.sidebar { width: 100%; height: auto; position: relative; border-right: none; border-bottom: 1px solid var(--border-color); }
|
|
.nav-links { display: flex; padding: 0; overflow-x: auto; }
|
|
.nav-links a { padding: .5rem 1rem; white-space: nowrap; }
|
|
.nav-links a.active { border-left: none; border-bottom: 2px solid var(--accent-blue); }
|
|
.content { margin-left: 0; padding: 1rem; }
|
|
body { flex-direction: column; }
|
|
.stack-card { flex-direction: column; align-items: flex-start; gap: .75rem; }
|
|
.stack-actions { width: 100%; justify-content: flex-end; }
|
|
.stack-grid { grid-template-columns: 1fr; }
|
|
.stats-grid { grid-template-columns: repeat(3, 1fr); }
|
|
.deploy-info { flex-direction: column; }
|
|
.system-info-items { flex-direction: column; gap: 1rem; }
|
|
}
|
|
`
|
|
|
|
// felhomLogoSVG is the felhom.eu logo, served at /static/felhom-logo.svg.
|
|
// Cleaned from the original Inkscape SVG, removing editor metadata.
|
|
const felhomLogoSVG = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<svg viewBox="0 0 645.30703 408.36403" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
<defs>
|
|
<linearGradient id="lg9"><stop offset="0"/><stop offset="0.99875164" style="stop-color:rgb(4,114,187)"/></linearGradient>
|
|
<linearGradient id="g1"><stop offset="0" style="stop-color:rgb(0,64,141)"/><stop offset="1" style="stop-color:rgb(0,141,223)"/></linearGradient>
|
|
<linearGradient id="g1-0" xlink:href="#g1" gradientUnits="userSpaceOnUse" x1="30.771" y1="2283.52" x2="30.771" y2="2416.4089" spreadMethod="pad" gradientTransform="matrix(0.999122,0,0,0.848244,1717.8096,192.633)"/>
|
|
<linearGradient id="g1-1" xlink:href="#g1" gradientUnits="userSpaceOnUse" x1="30.849001" y1="2446.3101" x2="30.849001" y2="2588.6721" gradientTransform="matrix(0.996573,0,0,0.791798,1717.8096,192.63306)"/>
|
|
<linearGradient id="g2"><stop offset="0.002"/><stop offset="1" style="stop-color:rgb(4,114,187)"/></linearGradient>
|
|
<linearGradient id="lg14" xlink:href="#g1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0.999122,0,0,0.848244,-306.27885,-1772.6719)" x1="30.771" y1="2283.52" x2="30.771" y2="2416.4089" spreadMethod="pad"/>
|
|
<linearGradient id="lg15" xlink:href="#g1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0.996573,0,0,0.791798,-306.27895,-1772.6718)" x1="30.849001" y1="2446.3101" x2="30.849001" y2="2588.6721"/>
|
|
<linearGradient id="lg17" xlink:href="#lg9" x1="160.76199" y1="268.35672" x2="284.18887" y2="268.80402" gradientUnits="userSpaceOnUse"/>
|
|
<linearGradient id="lg1" xlink:href="#lg9" gradientUnits="userSpaceOnUse" x1="160.76199" y1="268.35672" x2="284.18887" y2="268.80402" gradientTransform="translate(-9.2828853,15.718777)"/>
|
|
</defs>
|
|
<rect x="1740.2544" y="2129.6233" width="16.597" height="112.721" style="fill:url(#g1-0);stroke:url(#g1-1);paint-order:fill" transform="rotate(-89.99513)"/>
|
|
<g transform="translate(43.276659,-1.4142135)">
|
|
<text style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:140.132px;font-family:'Vremena Grotesk',Arial,sans-serif;text-align:center;text-anchor:middle;fill:#00408d;stroke:#051343;stroke-width:2.33554" x="189.29001" y="402.45694"><tspan x="189.29001" y="402.45694">f<tspan style="font-family:'M+ 2c',Arial,sans-serif">e</tspan>lhom</tspan></text>
|
|
<text style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:140.132px;font-family:'M+ 2c',Arial,sans-serif;text-align:center;text-anchor:middle;fill:#008ddf;stroke:#0472bb;stroke-width:2.33554" x="520.41119" y="401.63116"><tspan x="520.41119" y="401.63116">e<tspan style="font-family:'Vremena Grotesk',Arial,sans-serif">u</tspan></tspan></text>
|
|
<circle style="fill:#008ddf;stroke:#0472bb;stroke-width:1.85226" cx="426.38022" cy="392.96091" r="10.150504"/>
|
|
</g>
|
|
<g>
|
|
<path style="fill:#00408d;stroke:#051343;stroke-width:2px" d="m 153.81527,161.41782 v 28.90235 h 105.70117 v -10.96094 h -24.28711 v -17.94141 z m 20.66992,8.46485 a 5.556,5.556 0 0 1 5.55664,5.55664 5.556,5.556 0 0 1 -5.55664,5.55468 5.556,5.556 0 0 1 -5.55664,-5.55468 5.556,5.556 0 0 1 5.55664,-5.55664 z m 20.47852,0.0215 a 5.556,5.556 0 0 1 5.55664,5.55664 5.556,5.556 0 0 1 -5.55664,5.55664 5.556,5.556 0 0 1 -5.55664,-5.55664 5.556,5.556 0 0 1 5.55664,-5.55664 z m 20.25586,0.14258 a 5.556,5.556 0 0 1 5.55664,5.55664 5.556,5.556 0 0 1 -5.55664,5.55469 5.556,5.556 0 0 1 -5.55664,-5.55469 5.556,5.556 0 0 1 5.55664,-5.55664 z"/>
|
|
<path style="fill:#00408d;stroke:#051343;stroke-width:2px" d="m 153.72738,200.94907 v 28.90039 h 105.70117 v -28.90039 z m 20.67188,9.46289 a 5.556,5.556 0 0 1 5.55468,5.55664 5.556,5.556 0 0 1 -5.55468,5.55664 5.556,5.556 0 0 1 -5.55665,-5.55664 5.556,5.556 0 0 1 5.55665,-5.55664 z m 20.47656,0.0234 a 5.556,5.556 0 0 1 5.55664,5.55469 5.556,5.556 0 0 1 -5.55664,5.55664 5.556,5.556 0 0 1 -5.55469,-5.55664 5.556,5.556 0 0 1 5.55469,-5.55469 z"/>
|
|
<path style="fill:#00408d;stroke:#051343;stroke-width:1.9" d="m 197.77426,121.21274 v 28.90039 h 53.76953 l 38.35351,-28.90039 z m 20.67187,9.46289 a 5.556,5.556 0 0 1 5.55469,5.55664 5.556,5.556 0 0 1 -5.55469,5.55469 5.556,5.556 0 0 1 -5.55664,-5.55469 5.556,5.556 0 0 1 5.55664,-5.55664 z"/>
|
|
<path d="m 257.94649,264.90079 c -53.034,0 -99.796,-10.762 -127.437,-27.136 19.291,8.807 47.768,14.377 79.534,14.377 23.242,0 44.724,-2.982 62.135,-8.032 v -75.023 l -28.665,-0.001 115.772,-87.280004 48.409,36.495004 v -12.853 h 25.034 v 31.726 l 42.329,31.912 h -28.353 v 77.268 l -78.822,0.005 c -27.916,11.441 -66.856,18.542 -109.935,18.542 z m 102.245,-115.364 c -12.363,0 -22.385,10.022 -22.385,22.385 0,8.343 4.565,15.621 11.334,19.471 l -7.782,41.652 h 37.666 l -7.782,-41.652 c 6.769,-3.85 11.334,-11.127 11.334,-19.471 0,-12.363 -10.022,-22.385 -22.385,-22.385 z" style="fill:#00408d;stroke:#051343;stroke-width:2px"/>
|
|
<path style="fill:#008ddf;stroke:#0472bb" d="m 522.38281,159.26953 c -16.13,0.388 -18.85575,5.44224 -48.34375,27.74024 -31.401,25.206 -62.71092,49.374 -117.91992,67 -12.11688,3.8684 -22.84543,6.83673 -36.66992,9.16601 -3.52327,0.53538 -7.07021,1.0572 -10.49219,1.52734 -11.11981,1.52774 -21.588,2.81477 -31.93359,3.76563 l -0.37205,20.49219 c 13.36471,0.34187 35.32192,0.38638 61.20023,-0.23523 25.87831,-0.62162 55.67773,-1.90936 84.72687,-4.23198 29.04914,-2.32263 73.441,-8.14839 90.28521,-12.88581 11.83463,-3.32848 28.09255,-8.72249 37.92989,-19.04615 9.46724,-10.59763 17.11931,-21.57912 18.56188,-36.64771 0.76581,-7.99943 -1.41558,-15.75159 -4.01758,-22.93359 -7.941,-19.793 -24.84508,-34.14694 -42.95508,-33.71094 z"/>
|
|
<path d="m 297.34961,5.4492188 c -65.399,0 -119.66288,43.7706692 -129.92188,101.1386712 -0.925,-0.029 -1.85411,-0.043 -2.78711,-0.043 -48.98199,0 -88.689448,39.70841 -88.689448,88.69141 0,48.968 50.024968,85.48542 92.356838,89.13334 l -3.4892,-16.75561 c -32.82055,-3.42533 -71.546884,-33.52411 -71.546884,-73.16211 0,-40.362 33.061744,-73.31914 73.491744,-73.31914 1.843,0 3.67147,0.0672 5.48047,0.20117 v -0.2246 c 4,0.257 7.90183,0.8211 11.67383,1.6621 2.252,-55.916995 52.66917,-100.619136 114.53515,-100.619136 53.04001,0 97.66433,32.85775 110.73633,77.46875 0.309,-0.159 0.61964,-0.315703 0.93164,-0.470703 0.006,0.02 0.0116,0.04055 0.0176,0.06055 11.026,-5.378001 23.57472,-8.419922 36.88672,-8.419922 37.486,0 68.91847,24.124511 77.35547,56.603511 C 520.38986,108.44553 485.73998,78 443.58398,78 c -8.339,0 -16.38403,1.191298 -23.95703,3.404297 -19.419,-44.513 -66.84934,-75.9550782 -122.27734,-75.9550782 z" style="fill:#00408d;stroke:#051343;stroke-width:2px"/>
|
|
<path style="fill:url(#lg14);stroke:url(#lg15);paint-order:fill" d="m -267.63252,164.31767 -15.70118,-0.12404 -3.54906,37.20629 -1.95109,76.01487 20.15622,-0.37475 c -4.12353,-45.07233 -3.02437,-82.7491 1.04511,-112.72275 z" transform="rotate(-89.99513)"/>
|
|
<path style="fill:none;stroke:url(#lg17);stroke-width:1" d="m 161.24828,267.14668 c 40.90805,5.43949 88.8177,6.68378 159.17408,-4.13383"/>
|
|
<path style="fill:none;stroke:url(#lg1);stroke-width:1" d="m 151.96539,281.42915 c 19.27385,6.64292 99.62693,7.03138 134.89483,7.72"/>
|
|
</g>
|
|
</svg>` |