6080170367
- New route: GET /apps/{slug} renders info page with use cases, setup guide, prerequisites
- New API: POST /api/stacks/{name}/optional-config for updating optional env vars
- New structs: AppInfo, OptionalConfigGroup, OptionalConfigField in metadata.go
- UpdateOptionalConfig saves to app.yaml and restarts deployed stacks with new env vars
- Info page template with hero section, screenshots, info cards, optional config form
- Navigation: stack cards now link to /apps/{slug}, deploy page has "Részletek" link
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2027 lines
71 KiB
Go
2027 lines
71 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 + appInfoTmpl
|
||
|
||
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>
|
||
document.addEventListener('click', function(e) {
|
||
if (e.target.closest('a, button, .btn, input, select, textarea, .stack-actions, .stack-detail-actions')) return;
|
||
var card = e.target.closest('[data-href]');
|
||
if (card) window.location.href = card.dataset.href;
|
||
});
|
||
async function checkBeforeDeploy(e, name) {
|
||
try {
|
||
var resp = await fetch('/api/stacks/' + name);
|
||
var data = await resp.json();
|
||
if (data.ok && data.data && data.data.deployed) {
|
||
e.preventDefault();
|
||
alert('Ez az alkalmazás már telepítve van.');
|
||
window.location.reload();
|
||
return false;
|
||
}
|
||
} catch(err) {}
|
||
return true;
|
||
}
|
||
async function syncTemplates() {
|
||
const btn = document.getElementById('sync-btn');
|
||
const toast = document.getElementById('sync-toast');
|
||
if (!btn) return;
|
||
const origText = btn.innerHTML;
|
||
btn.disabled = true;
|
||
btn.innerHTML = '↻ Frissítés...';
|
||
btn.classList.add('loading');
|
||
try {
|
||
const resp = await fetch('/api/sync', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'}
|
||
});
|
||
const data = await resp.json();
|
||
if (toast) {
|
||
toast.textContent = data.ok ? (data.message || 'Sablonok frissítve') : ('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||
toast.className = 'sync-toast ' + (data.ok ? 'sync-toast-ok' : 'sync-toast-err');
|
||
toast.style.display = 'block';
|
||
setTimeout(function() { toast.style.display = 'none'; }, 5000);
|
||
}
|
||
if (data.ok && data.data && (data.data.new_apps && data.data.new_apps.length > 0 || data.data.updated && data.data.updated.length > 0)) {
|
||
setTimeout(function() { window.location.reload(); }, 1500);
|
||
}
|
||
} catch (err) {
|
||
if (toast) {
|
||
toast.textContent = 'Hálózati hiba: ' + err.message;
|
||
toast.className = 'sync-toast sync-toast-err';
|
||
toast.style.display = 'block';
|
||
setTimeout(function() { toast.style.display = 'none'; }, 5000);
|
||
}
|
||
}
|
||
btn.innerHTML = origText;
|
||
btn.disabled = false;
|
||
btn.classList.remove('loading');
|
||
}
|
||
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}}"{{if not .Protected}} data-href="/apps/{{.Meta.Slug}}"{{end}}>
|
||
<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" onclick="return checkBeforeDeploy(event, '{{.Name}}')">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>
|
||
<button class="btn btn-sm btn-outline" id="sync-btn" onclick="syncTemplates()" title="Sablonok frissítése a központi katalógusból">↻ Sablonok frissítése</button>
|
||
</div>
|
||
<div id="sync-toast" class="sync-toast" style="display:none"></div>
|
||
|
||
<div class="stack-grid">
|
||
{{range .Stacks}}
|
||
<div class="stack-detail-card stack-state-{{stateColor .State}}"{{if not .Protected}} data-href="/apps/{{.Meta.Slug}}"{{end}}>
|
||
<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" onclick="return checkBeforeDeploy(event, '{{.Name}}')">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">
|
||
<div style="display:flex;align-items:center;gap:.5rem">
|
||
<a href="/stacks" class="btn btn-sm btn-outline">← Vissza</a>
|
||
<h2>{{.Meta.DisplayName}} — Telepítés</h2>
|
||
</div>
|
||
<a href="/apps/{{.Meta.Slug}}" class="btn btn-sm btn-outline">ℹ️ Részletek</a>
|
||
</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="/apps/{{.Meta.Slug}}" 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="memory-bar-stacked">
|
||
<div class="memory-bar-segment memory-bar-committed" style="width:{{.CommittedPercent}}%" title="Jelenlegi foglalás: {{.CommittedMB}} MB"></div>
|
||
<div class="memory-bar-segment memory-bar-new" style="width:{{subtract .Percent .CommittedPercent}}%" title="{{$.Meta.DisplayName}}: +{{.NewRequestMB}} MB"></div>
|
||
</div>
|
||
<div class="memory-bar-legend">
|
||
<span class="memory-legend-item"><span class="memory-legend-dot memory-legend-committed"></span>Jelenlegi foglalás ({{.CommittedMB}} MB)</span>
|
||
<span class="memory-legend-item"><span class="memory-legend-dot memory-legend-new"></span>{{$.Meta.DisplayName}} (+{{.NewRequestMB}} MB)</span>
|
||
</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 id="deploy-progress" class="deploy-progress" style="display:none">
|
||
<h3>Telepítés folyamatban...</h3>
|
||
<div class="deploy-steps">
|
||
<div class="deploy-step active" id="step-config">
|
||
<span class="step-icon">⏳</span>
|
||
<span class="step-text">Konfiguráció mentése...</span>
|
||
</div>
|
||
<div class="deploy-step" id="step-containers">
|
||
<span class="step-icon">⏳</span>
|
||
<span class="step-text">Konténer(ek) indítása...</span>
|
||
</div>
|
||
<div class="deploy-step" id="step-health">
|
||
<span class="step-icon">⏳</span>
|
||
<span class="step-text">Alkalmazás inicializálása...</span>
|
||
</div>
|
||
</div>
|
||
<div id="deploy-warning" class="alert alert-warning" style="display:none"></div>
|
||
<div id="deploy-result" style="display:none"></div>
|
||
<p class="deploy-elapsed" id="deploy-elapsed"></p>
|
||
</div>
|
||
</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]');
|
||
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;
|
||
}
|
||
}
|
||
});
|
||
|
||
var stackName = '{{.Stack.Name}}';
|
||
var progressEl = document.getElementById('deploy-progress');
|
||
var formEl = document.getElementById('deploy-form');
|
||
var stepConfig = document.getElementById('step-config');
|
||
var stepContainers = document.getElementById('step-containers');
|
||
var stepHealth = document.getElementById('step-health');
|
||
var warningEl = document.getElementById('deploy-warning');
|
||
var resultEl = document.getElementById('deploy-result');
|
||
var elapsedEl = document.getElementById('deploy-elapsed');
|
||
|
||
function setStep(el, status, text) {
|
||
el.className = 'deploy-step ' + status;
|
||
if (text) el.querySelector('.step-text').textContent = text;
|
||
var icon = el.querySelector('.step-icon');
|
||
if (status === 'done') icon.textContent = '\u2705';
|
||
else if (status === 'error') icon.textContent = '\u274C';
|
||
else if (status === 'warn') icon.textContent = '\u26A0\uFE0F';
|
||
else if (status === 'active') icon.textContent = '\u23F3';
|
||
}
|
||
|
||
// Phase 1: Deploy request
|
||
try {
|
||
var resp = await fetch('/api/stacks/' + stackName + '/deploy', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({values: values})
|
||
});
|
||
var data = await resp.json();
|
||
if (!data.ok) {
|
||
alert('Hiba: ' + data.error);
|
||
btn.textContent = 'Telepítés indítása';
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
|
||
// Deploy API returned success — switch to progress view
|
||
formEl.style.display = 'none';
|
||
progressEl.style.display = 'block';
|
||
setStep(stepConfig, 'done', 'Konfiguráció mentve');
|
||
setStep(stepContainers, 'active', 'Konténer(ek) indítása...');
|
||
|
||
if (data.data && data.data.warning) {
|
||
warningEl.textContent = data.data.warning;
|
||
warningEl.style.display = 'block';
|
||
}
|
||
|
||
// Phase 2: Poll stack status
|
||
var startTime = Date.now();
|
||
var pollTimeout = 120000;
|
||
var pollTimer = setInterval(async function() {
|
||
var elapsed = Math.round((Date.now() - startTime) / 1000);
|
||
elapsedEl.textContent = elapsed + ' másodperce...';
|
||
|
||
if (Date.now() - startTime > pollTimeout) {
|
||
clearInterval(pollTimer);
|
||
setStep(stepHealth, 'warn', 'Időtúllépés — az alkalmazás még indulhat');
|
||
resultEl.innerHTML = '<div class="alert alert-warning" style="margin-top:1rem">' +
|
||
'A telepítés időtúllépésbe futott. Az alkalmazás még indulhat.' +
|
||
'</div><a href="/stacks" class="btn btn-primary" style="margin-top:.75rem">Alkalmazások megtekintése</a>';
|
||
resultEl.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
var sr = await fetch('/api/stacks/' + stackName);
|
||
var sd = await sr.json();
|
||
if (!sd.ok || !sd.data) return;
|
||
var state = sd.data.state;
|
||
|
||
if (state === 'running') {
|
||
clearInterval(pollTimer);
|
||
setStep(stepContainers, 'done', 'Konténerek elindultak');
|
||
setStep(stepHealth, 'done', 'Alkalmazás kész!');
|
||
progressEl.querySelector('h3').textContent = 'Telepítés sikeres!';
|
||
resultEl.innerHTML = '<div class="alert alert-info" style="margin-top:1rem">' +
|
||
'Az alkalmazás fut. Átirányítás 3 másodperc múlva...' +
|
||
'</div>';
|
||
resultEl.style.display = 'block';
|
||
setTimeout(function() { window.location.href = '/stacks'; }, 3000);
|
||
} else if (state === 'starting') {
|
||
setStep(stepContainers, 'done', 'Konténerek elindultak');
|
||
setStep(stepHealth, 'active', 'Alkalmazás inicializálása...');
|
||
} else if (state === 'unhealthy') {
|
||
clearInterval(pollTimer);
|
||
setStep(stepContainers, 'done', 'Konténerek elindultak');
|
||
setStep(stepHealth, 'warn', 'Állapotjelző: nem egészséges');
|
||
resultEl.innerHTML = '<div class="alert alert-warning" style="margin-top:1rem">' +
|
||
'Az alkalmazás elindult, de az állapotjelző nem egészséges. ' +
|
||
'Ez normális lehet az első percekben.' +
|
||
'</div><a href="/stacks" class="btn btn-primary" style="margin-top:.75rem">Alkalmazások megtekintése</a>';
|
||
resultEl.style.display = 'block';
|
||
} else if (state === 'exited' || state === 'stopped') {
|
||
clearInterval(pollTimer);
|
||
setStep(stepContainers, 'error', 'A konténer leállt');
|
||
setStep(stepHealth, 'error');
|
||
progressEl.querySelector('h3').textContent = 'Telepítés sikertelen';
|
||
resultEl.innerHTML = '<div class="alert alert-error" style="margin-top:1rem">' +
|
||
'A konténer leállt. Ellenőrizze a naplókat.' +
|
||
'</div><a href="/stacks/' + stackName + '/logs" class="btn btn-outline" style="margin-top:.75rem">Naplók megtekintése</a>' +
|
||
' <a href="/stacks" class="btn btn-primary" style="margin-top:.75rem">Alkalmazások</a>';
|
||
resultEl.style.display = 'block';
|
||
}
|
||
} catch(pollErr) {}
|
||
}, 3000);
|
||
|
||
} catch (err) {
|
||
alert('Hálózati hiba: ' + err.message);
|
||
btn.textContent = 'Telepítés indítása';
|
||
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" id="logs-container">
|
||
<pre class="logs-output" id="logs-output">{{.Logs}}</pre>
|
||
</div>
|
||
<div class="logs-actions">
|
||
<span class="logs-live-indicator" id="live-indicator">
|
||
<span class="logs-live-dot"></span> Élő
|
||
</span>
|
||
<button class="btn btn-outline btn-sm" id="logs-toggle" onclick="toggleLive()">Szüneteltetés</button>
|
||
<button class="btn btn-outline btn-sm" onclick="fetchLogs()">Frissítés</button>
|
||
</div>
|
||
<script>
|
||
(function() {
|
||
var container = document.getElementById('logs-container');
|
||
var output = document.getElementById('logs-output');
|
||
var indicator = document.getElementById('live-indicator');
|
||
var toggleBtn = document.getElementById('logs-toggle');
|
||
var live = true;
|
||
var timer = null;
|
||
var stackName = '{{.Stack.Name}}';
|
||
|
||
function isAtBottom() {
|
||
return container.scrollHeight - container.scrollTop - container.clientHeight < 50;
|
||
}
|
||
|
||
window.fetchLogs = function() {
|
||
fetch('/stacks/' + stackName + '/logs?raw=1')
|
||
.then(function(r) { return r.text(); })
|
||
.then(function(text) {
|
||
var wasAtBottom = isAtBottom();
|
||
output.textContent = text;
|
||
if (wasAtBottom) container.scrollTop = container.scrollHeight;
|
||
})
|
||
.catch(function() {});
|
||
};
|
||
|
||
function startPolling() {
|
||
if (timer) return;
|
||
timer = setInterval(window.fetchLogs, 3000);
|
||
}
|
||
|
||
function stopPolling() {
|
||
if (timer) { clearInterval(timer); timer = null; }
|
||
}
|
||
|
||
window.toggleLive = function() {
|
||
live = !live;
|
||
if (live) {
|
||
startPolling();
|
||
indicator.className = 'logs-live-indicator';
|
||
indicator.innerHTML = '<span class="logs-live-dot"></span> Élő';
|
||
toggleBtn.textContent = 'Szüneteltetés';
|
||
} else {
|
||
stopPolling();
|
||
indicator.className = 'logs-live-indicator logs-live-paused';
|
||
indicator.innerHTML = '⏸ Szünetelve';
|
||
toggleBtn.textContent = 'Folytatás';
|
||
}
|
||
};
|
||
|
||
// Auto-scroll to bottom on initial load
|
||
container.scrollTop = container.scrollHeight;
|
||
startPolling();
|
||
})();
|
||
</script>
|
||
{{template "layout_end" .}}
|
||
{{end}}
|
||
`
|
||
|
||
const appInfoTmpl = `
|
||
{{define "app_info"}}
|
||
{{template "layout_start" .}}
|
||
|
||
<div class="page-header">
|
||
<div style="display:flex;align-items:center;gap:1rem">
|
||
<a href="/stacks" class="btn btn-sm btn-outline">← Alkalmazások</a>
|
||
<h2>{{.Meta.DisplayName}}</h2>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:.5rem">
|
||
{{if .Stack.Deployed}}
|
||
<span class="stack-state-badge state-{{stateColor .Stack.State}}">{{stateLabel .Stack.State}}</span>
|
||
<a href="https://{{.Meta.Subdomain}}.{{.Domain}}" target="_blank" class="btn btn-sm btn-outline">Megnyitás ↗</a>
|
||
<a href="/stacks/{{.Stack.Name}}/logs" class="btn btn-sm btn-outline">Napló</a>
|
||
<a href="/stacks/{{.Stack.Name}}/deploy" class="btn btn-sm btn-outline">Beállítások</a>
|
||
{{else}}
|
||
<a href="/stacks/{{.Stack.Name}}/deploy" class="btn btn-sm btn-primary" onclick="return checkBeforeDeploy(event, '{{.Stack.Name}}')">Telepítés</a>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Hero section -->
|
||
<div class="app-info-hero">
|
||
<img class="app-info-logo" src="{{logoURL .Meta.Slug}}"
|
||
alt="{{.Meta.DisplayName}}"
|
||
onerror="this.onerror=function(){this.style.display='none'};this.src='{{logoPNGURL .Meta.Slug}}'">
|
||
<div class="app-info-hero-text">
|
||
{{if .AppInfo.Tagline}}
|
||
<p class="app-info-tagline">{{.AppInfo.Tagline}}</p>
|
||
{{else}}
|
||
<p class="app-info-tagline">{{.Meta.Description}}</p>
|
||
{{end}}
|
||
<div class="stack-meta-badges">
|
||
<span class="meta-badge">~{{.Meta.Resources.MemRequest}} RAM</span>
|
||
<span class="meta-badge">{{.Meta.Category}}</span>
|
||
{{if .Meta.Resources.NeedsHDD}}<span class="meta-badge meta-badge-warn">HDD szükséges</span>{{end}}
|
||
{{if .Meta.Resources.PiCompatible}}<span class="meta-badge meta-badge-ok">Pi kompatibilis</span>{{else}}<span class="meta-badge meta-badge-warn">Csak x86</span>{{end}}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Screenshots (graceful — hidden if assets don't exist) -->
|
||
<div class="app-screenshots" id="screenshots">
|
||
<img src="{{screenshotURL .Meta.Slug 1}}" alt="" class="app-screenshot"
|
||
onerror="this.style.display='none'">
|
||
<img src="{{screenshotURL .Meta.Slug 2}}" alt="" class="app-screenshot"
|
||
onerror="this.style.display='none'">
|
||
<img src="{{screenshotURL .Meta.Slug 3}}" alt="" class="app-screenshot"
|
||
onerror="this.style.display='none'">
|
||
</div>
|
||
|
||
{{if .HasAppInfo}}
|
||
<div class="app-info-grid">
|
||
{{if .AppInfo.UseCases}}
|
||
<div class="app-info-card">
|
||
<h3>Mire használható?</h3>
|
||
<ul class="app-info-list">
|
||
{{range .AppInfo.UseCases}}<li>{{.}}</li>{{end}}
|
||
</ul>
|
||
</div>
|
||
{{end}}
|
||
|
||
{{if .AppInfo.FirstSteps}}
|
||
<div class="app-info-card">
|
||
<h3>Első lépések</h3>
|
||
<ol class="app-info-list">
|
||
{{range .AppInfo.FirstSteps}}<li>{{.}}</li>{{end}}
|
||
</ol>
|
||
</div>
|
||
{{end}}
|
||
|
||
{{if .AppInfo.Prerequisites}}
|
||
<div class="app-info-card">
|
||
<h3>Előfeltételek</h3>
|
||
<ul class="app-info-list">
|
||
{{range .AppInfo.Prerequisites}}<li>{{.}}</li>{{end}}
|
||
</ul>
|
||
</div>
|
||
{{end}}
|
||
|
||
{{if .AppInfo.DefaultCreds}}
|
||
<div class="app-info-card">
|
||
<h3>Alapértelmezett belépés</h3>
|
||
<p class="app-info-creds">{{.AppInfo.DefaultCreds}}</p>
|
||
<p class="app-info-creds-warn">Az első bejelentkezés után azonnal változtasd meg!</p>
|
||
</div>
|
||
{{end}}
|
||
|
||
{{if .AppInfo.DocsURL}}
|
||
<div class="app-info-card">
|
||
<h3>Dokumentáció</h3>
|
||
<p><a href="{{.AppInfo.DocsURL}}" target="_blank" class="app-info-link">Hivatalos dokumentáció ↗</a></p>
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
{{end}}
|
||
|
||
{{if .HasOptionalConfig}}
|
||
<div class="app-optional-config">
|
||
<h3>Opcionális beállítások</h3>
|
||
{{range .OptionalConfig}}
|
||
<div class="config-group">
|
||
<h4>{{.Group}}</h4>
|
||
{{if .Description}}<p class="config-group-desc">{{.Description}}</p>{{end}}
|
||
|
||
<div class="config-fields">
|
||
{{range .Fields}}
|
||
<div class="config-field">
|
||
<label for="opt-{{.EnvVar}}">{{.Label}}</label>
|
||
{{if .HelpText}}<p class="config-field-help">{{.HelpText}}</p>{{end}}
|
||
{{if .HelpURL}}<p class="config-field-help"><a href="{{.HelpURL}}" target="_blank">Regisztrációs útmutató ↗</a></p>{{end}}
|
||
<input type="{{if eq .Type "secret_input"}}password{{else}}text{{end}}"
|
||
id="opt-{{.EnvVar}}"
|
||
name="{{.EnvVar}}"
|
||
class="config-input"
|
||
value="{{index $.CurrentValues .EnvVar}}"
|
||
placeholder="{{.Label}}"
|
||
autocomplete="off">
|
||
</div>
|
||
{{end}}
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
|
||
<div class="config-actions">
|
||
<button class="btn btn-primary" id="save-optional-config" onclick="saveOptionalConfig('{{.Stack.Name}}')">
|
||
Mentés
|
||
</button>
|
||
<span id="config-save-status" class="config-save-status"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
async function saveOptionalConfig(stackName) {
|
||
const btn = document.getElementById('save-optional-config');
|
||
const status = document.getElementById('config-save-status');
|
||
const inputs = document.querySelectorAll('.config-input');
|
||
|
||
const values = {};
|
||
inputs.forEach(function(input) {
|
||
values[input.name] = input.value;
|
||
});
|
||
|
||
btn.disabled = true;
|
||
btn.textContent = 'Mentés...';
|
||
status.textContent = '';
|
||
status.className = 'config-save-status';
|
||
|
||
try {
|
||
const resp = await fetch('/api/stacks/' + stackName + '/optional-config', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({values: values})
|
||
});
|
||
const data = await resp.json();
|
||
|
||
if (data.ok) {
|
||
status.textContent = (data.message || 'Mentve');
|
||
status.className = 'config-save-status config-save-ok';
|
||
} else {
|
||
status.textContent = (data.error || 'Hiba');
|
||
status.className = 'config-save-status config-save-err';
|
||
}
|
||
} catch(err) {
|
||
status.textContent = 'Hálózati hiba';
|
||
status.className = 'config-save-status config-save-err';
|
||
}
|
||
|
||
btn.disabled = false;
|
||
btn.textContent = 'Mentés';
|
||
|
||
setTimeout(function() { status.textContent = ''; }, 5000);
|
||
}
|
||
</script>
|
||
{{end}}
|
||
|
||
{{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);
|
||
}
|
||
|
||
/* Deploy progress */
|
||
.deploy-progress {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: var(--radius);
|
||
padding: 1.5rem;
|
||
}
|
||
.deploy-progress h3 {
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
.deploy-steps {
|
||
margin: 1rem 0;
|
||
}
|
||
.deploy-step {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
padding: 0.5rem 0;
|
||
font-size: .95rem;
|
||
color: var(--text-muted);
|
||
}
|
||
.deploy-step.active {
|
||
color: var(--text-primary);
|
||
}
|
||
.deploy-step.done {
|
||
color: var(--text-primary);
|
||
}
|
||
.deploy-step.done .step-icon {
|
||
color: var(--green);
|
||
}
|
||
.deploy-step.error .step-icon {
|
||
color: var(--red);
|
||
}
|
||
.deploy-step.warn .step-icon {
|
||
color: var(--yellow);
|
||
}
|
||
.step-icon {
|
||
font-size: 1.1rem;
|
||
width: 1.5rem;
|
||
text-align: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.deploy-elapsed {
|
||
color: var(--text-muted);
|
||
font-size: .85rem;
|
||
margin-top: 0.5rem;
|
||
}
|
||
|
||
/* 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;
|
||
}
|
||
.memory-bar-stacked {
|
||
width: 100%;
|
||
height: 10px;
|
||
border-radius: 5px;
|
||
display: flex;
|
||
overflow: hidden;
|
||
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%
|
||
);
|
||
}
|
||
.memory-bar-stacked::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: 70%;
|
||
top: -1px;
|
||
bottom: -1px;
|
||
width: 2px;
|
||
background: var(--yellow);
|
||
border-radius: 1px;
|
||
opacity: 0.5;
|
||
z-index: 2;
|
||
}
|
||
.memory-bar-stacked::after {
|
||
content: '';
|
||
position: absolute;
|
||
left: 85%;
|
||
top: -1px;
|
||
bottom: -1px;
|
||
width: 2px;
|
||
background: var(--red);
|
||
border-radius: 1px;
|
||
opacity: 0.5;
|
||
z-index: 2;
|
||
}
|
||
.memory-bar-segment {
|
||
height: 100%;
|
||
position: relative;
|
||
z-index: 1;
|
||
transition: width 0.3s ease;
|
||
}
|
||
.memory-bar-segment:not([style*="width:0%"]) {
|
||
min-width: 3px;
|
||
}
|
||
.memory-bar-committed {
|
||
background: var(--green);
|
||
box-shadow: 0 0 6px rgba(35, 134, 54, 0.4);
|
||
border-radius: 5px 0 0 5px;
|
||
}
|
||
.memory-bar-new {
|
||
background: rgba(35, 134, 54, 0.45);
|
||
border-right: 2px solid #4edf72;
|
||
border-radius: 0 5px 5px 0;
|
||
}
|
||
.memory-bar-legend {
|
||
display: flex;
|
||
gap: 1.25rem;
|
||
margin-top: 0.5rem;
|
||
}
|
||
.memory-legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
font-size: .75rem;
|
||
color: var(--text-secondary);
|
||
}
|
||
.memory-legend-dot {
|
||
display: inline-block;
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 3px;
|
||
flex-shrink: 0;
|
||
}
|
||
.memory-legend-committed {
|
||
background: var(--green);
|
||
}
|
||
.memory-legend-new {
|
||
background: rgba(35, 134, 54, 0.45);
|
||
border: 1px solid #4edf72;
|
||
}
|
||
|
||
/* Logs */
|
||
.logs-container {
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: var(--radius);
|
||
padding: 1rem;
|
||
overflow-x: auto;
|
||
overflow-y: auto;
|
||
max-height: 70vh;
|
||
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;
|
||
margin: 0;
|
||
}
|
||
.logs-actions {
|
||
display: flex;
|
||
gap: .5rem;
|
||
align-items: center;
|
||
}
|
||
.logs-live-indicator {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: .35rem;
|
||
font-size: .8rem;
|
||
font-weight: 600;
|
||
color: var(--green);
|
||
margin-right: .5rem;
|
||
}
|
||
.logs-live-indicator.logs-live-paused {
|
||
color: var(--text-muted);
|
||
}
|
||
.logs-live-dot {
|
||
display: inline-block;
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background: var(--green);
|
||
animation: logs-pulse 1.5s ease-in-out infinite;
|
||
}
|
||
@keyframes logs-pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.3; }
|
||
}
|
||
|
||
/* Sync toast */
|
||
.sync-toast {
|
||
padding: .6rem 1rem;
|
||
border-radius: 8px;
|
||
font-size: .85rem;
|
||
margin-bottom: 1rem;
|
||
border: 1px solid;
|
||
transition: opacity 0.3s ease;
|
||
}
|
||
.sync-toast-ok {
|
||
background: var(--green-bg);
|
||
color: var(--green);
|
||
border-color: rgba(35, 134, 54, 0.3);
|
||
}
|
||
.sync-toast-err {
|
||
background: var(--red-bg);
|
||
color: var(--red);
|
||
border-color: rgba(218, 54, 51, 0.3);
|
||
}
|
||
|
||
/* Clickable cards */
|
||
[data-href] { cursor: pointer; }
|
||
|
||
.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); }
|
||
|
||
/* --- App Info Page --- */
|
||
.app-info-hero {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1.5rem;
|
||
padding: 1.5rem;
|
||
background: var(--bg-card);
|
||
border-radius: var(--radius);
|
||
border: 1px solid var(--border-color);
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
.app-info-logo {
|
||
width: 80px;
|
||
height: 80px;
|
||
border-radius: 12px;
|
||
object-fit: contain;
|
||
background: var(--bg-secondary);
|
||
padding: 10px;
|
||
flex-shrink: 0;
|
||
}
|
||
.app-info-tagline {
|
||
font-size: 1.1rem;
|
||
color: var(--text-primary);
|
||
margin: 0 0 .75rem 0;
|
||
}
|
||
.app-screenshots {
|
||
display: flex;
|
||
gap: 1rem;
|
||
margin-bottom: 1.5rem;
|
||
overflow-x: auto;
|
||
padding-bottom: .5rem;
|
||
}
|
||
.app-screenshot {
|
||
max-height: 220px;
|
||
border-radius: var(--radius);
|
||
border: 1px solid var(--border-color);
|
||
object-fit: cover;
|
||
}
|
||
.app-info-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||
gap: 1rem;
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
.app-info-card {
|
||
background: var(--bg-card);
|
||
border-radius: var(--radius);
|
||
padding: 1.25rem;
|
||
border: 1px solid var(--border-color);
|
||
}
|
||
.app-info-card h3 {
|
||
margin: 0 0 .75rem 0;
|
||
font-size: .95rem;
|
||
color: var(--text-primary);
|
||
}
|
||
.app-info-list {
|
||
margin: 0;
|
||
padding-left: 1.25rem;
|
||
color: var(--text-secondary);
|
||
font-size: .9rem;
|
||
line-height: 1.6;
|
||
}
|
||
.app-info-creds {
|
||
font-family: monospace;
|
||
font-size: 1rem;
|
||
color: var(--accent-light);
|
||
background: var(--bg-secondary);
|
||
padding: .5rem .75rem;
|
||
border-radius: 4px;
|
||
display: inline-block;
|
||
margin: 0 0 .5rem 0;
|
||
}
|
||
.app-info-creds-warn {
|
||
color: var(--orange);
|
||
font-size: .85rem;
|
||
margin: 0;
|
||
}
|
||
.app-info-link {
|
||
color: var(--accent-light);
|
||
text-decoration: none;
|
||
}
|
||
.app-info-link:hover { text-decoration: underline; }
|
||
|
||
/* Optional config section */
|
||
.app-optional-config {
|
||
background: var(--bg-card);
|
||
border-radius: var(--radius);
|
||
padding: 1.5rem;
|
||
border: 1px solid var(--border-color);
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
.app-optional-config h3 {
|
||
margin: 0 0 1rem 0;
|
||
font-size: 1.05rem;
|
||
}
|
||
.config-group {
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
.config-group h4 {
|
||
margin: 0 0 .5rem 0;
|
||
font-size: .95rem;
|
||
color: var(--text-primary);
|
||
}
|
||
.config-group-desc {
|
||
color: var(--text-secondary);
|
||
font-size: .85rem;
|
||
margin: 0 0 1rem 0;
|
||
}
|
||
.config-fields {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||
gap: 1rem;
|
||
}
|
||
.config-field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: .25rem;
|
||
}
|
||
.config-field label {
|
||
font-size: .85rem;
|
||
font-weight: 500;
|
||
color: var(--text-primary);
|
||
}
|
||
.config-field-help {
|
||
font-size: .8rem;
|
||
color: var(--text-secondary);
|
||
margin: 0;
|
||
line-height: 1.4;
|
||
}
|
||
.config-field-help a {
|
||
color: var(--accent-light);
|
||
text-decoration: none;
|
||
}
|
||
.config-field-help a:hover { text-decoration: underline; }
|
||
.config-input {
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 4px;
|
||
padding: .5rem .75rem;
|
||
color: var(--text-primary);
|
||
font-size: .9rem;
|
||
font-family: monospace;
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
}
|
||
.config-input:focus {
|
||
outline: none;
|
||
border-color: var(--accent-blue);
|
||
}
|
||
.config-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
margin-top: 1rem;
|
||
}
|
||
.config-save-status {
|
||
font-size: .9rem;
|
||
transition: opacity .3s;
|
||
}
|
||
.config-save-ok { color: var(--green); }
|
||
.config-save-err { color: var(--red); }
|
||
.meta-badge-warn {
|
||
background: rgba(255, 152, 0, 0.1) !important;
|
||
color: var(--orange) !important;
|
||
}
|
||
.meta-badge-ok {
|
||
background: rgba(76, 175, 80, 0.1) !important;
|
||
color: var(--green) !important;
|
||
}
|
||
|
||
/* 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>` |