diff --git a/internal/http/static/app.css b/internal/http/static/app.css index 366e7a9..50b389e 100644 --- a/internal/http/static/app.css +++ b/internal/http/static/app.css @@ -461,6 +461,95 @@ form .grid { gap: 1.2em; margin-bottom: 0.9em; } @keyframes sheet-pop { from { transform: scale(0.98); opacity: 0; } to { transform: none; opacity: 1; } } @media (prefers-reduced-motion: reduce) { .sheet[open] { animation: none; } } +[hidden] { display: none !important; } + +/* List rows: one table, card rows below 700px (static/list.js for filter/sort) */ +.listbar { margin-bottom: 12px; } +.search { position: relative; margin-bottom: 8px; } +.search .icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; color: var(--muted); pointer-events: none; } +.search input { padding-left: 40px; min-height: 44px; font-size: 16px; } +.chips { + display: flex; + gap: 6px; + overflow-x: auto; + scrollbar-width: none; + -webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent); + mask-image: linear-gradient(to right, black calc(100% - 24px), transparent); +} +.chips::-webkit-scrollbar { display: none; } +.chip { + flex: 0 0 auto; + min-height: 32px; + padding: 0 12px; + border-radius: 9999px; + border: 1px solid var(--line-strong); + background: var(--surface); + color: var(--ink2); + font-size: 12px; + font-weight: 600; + text-transform: capitalize; +} +.chip[aria-pressed=true] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent-soft); } +.list-meta { margin: 8px 0 0; font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 8px; } +.list-meta .btn-link { font-size: 12px; } +.empty-note { color: var(--muted); text-align: center; padding: 24px 0; } +.empty-icon { display: inline-flex; color: var(--muted); margin-bottom: 8px; } +.empty-icon .icon { width: 32px; height: 32px; } +.section-head { display: flex; align-items: center; gap: 8px; margin: 24px 0 12px; } +.section-head h2 { margin: 0; font-size: 15px; font-weight: 700; } +.section-head .count { background: var(--sunk); color: var(--ink2); border-radius: 9999px; padding: 0 8px; font-size: 12px; font-weight: 700; } +.section-head .btn-link { margin-left: auto; font-size: 13px; } +.stat-ok { color: var(--ok); } +.group-head { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 8px; margin: 16px 0 8px; } +.group-head h3 { margin: 0; font-size: 15px; font-weight: 700; text-transform: none; letter-spacing: 0; color: var(--ink); } +.group-head .mono { font-size: 12px; color: var(--muted); } +.group-count { font-size: 12px; color: var(--muted); } +table.rows { border-collapse: separate; border-spacing: 0; border: 1px solid var(--line); border-radius: 16px; overflow: hidden; background: var(--surface); box-shadow: var(--shadow); margin-bottom: 12px; } +.rows thead th { padding: 0; font-size: 11px; font-weight: 800; text-transform: uppercase; color: var(--muted); background: var(--sunk); letter-spacing: 0.04em; } +.th-sort { display: inline-flex; align-items: center; gap: 4px; width: 100%; min-height: 36px; padding: 0 12px; border: 0; border-radius: 0; background: none; box-shadow: none; color: inherit; font: inherit; text-transform: inherit; letter-spacing: inherit; cursor: pointer; } +.th-sort:hover { background: none; color: var(--ink); } +.th-sort .icon { width: 12px; height: 12px; opacity: 0; transition: transform 0.15s; } +th[aria-sort=ascending] .icon, th[aria-sort=descending] .icon { opacity: 1; } +th[aria-sort=ascending] .icon { transform: rotate(180deg); } +.rows tbody td { padding: 10px 12px; vertical-align: middle; font-size: 14px; border-bottom: 1px solid var(--line); } +.rows tbody tr:last-child td { border-bottom: 0; } +.rows tbody tr:hover td { background: var(--sunk); } +.rows .c-name > a:first-child { font-size: 15px; font-weight: 700; display: block; } +.rows .c-name small { display: block; font-size: 12px; color: var(--muted); } +.rows .c-name small a { font-weight: 400; color: var(--muted); } +.rows .c-name small a.ext::after { content: " \2197"; } +.rows .c-num { font-size: 12px; white-space: nowrap; } +.rows .badge { white-space: nowrap; } +progress.bar { display: block; width: 100%; max-width: 96px; height: 4px; margin-top: 4px; border: 0; border-radius: 2px; background: var(--chart-soft); appearance: none; -webkit-appearance: none; } +progress.bar::-webkit-progress-bar { background: var(--chart-soft); border-radius: 2px; } +progress.bar::-webkit-progress-value { background: var(--chart); border-radius: 2px; } +progress.bar::-moz-progress-bar { background: var(--chart); border-radius: 2px; } +progress.bar-warn::-webkit-progress-value { background: var(--warn); } +progress.bar-warn::-moz-progress-bar { background: var(--warn); } +progress.bar-danger::-webkit-progress-value { background: var(--danger); } +progress.bar-danger::-moz-progress-bar { background: var(--danger); } +@media (max-width: 699px) { + table.rows, .rows tbody { display: block; border: 0; background: none; box-shadow: none; border-radius: 0; overflow: visible; } + .rows thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } + .rows tbody tr { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px 12px; + padding: 12px 14px; + margin-bottom: 8px; + border: 1px solid var(--line); + border-radius: 16px; + background: var(--surface); + box-shadow: var(--shadow); + } + .rows tbody td { display: block; padding: 0; border: 0; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .rows tbody tr:hover td { background: none; } + .rows td.c-name { grid-column: 1 / span 2; } + .rows td.c-status { justify-self: end; } + .rows td[data-label]::before { content: attr(data-label); display: block; font-size: 11px; font-weight: 800; text-transform: uppercase; color: var(--muted); } + .col-sm-hide { display: none !important; } +} + /* Tables */ .table-wrap { width: 100%; @@ -565,8 +654,8 @@ small { font-size: 0.82em; color: var(--text-dim); display: block; margin-top: 0 /* Stat Cards */ .stat { text-align: left; padding: 1.1em 1.25em; position: relative; overflow: hidden; } -.stat h2 { font-size: 1.75em; font-weight: 700; margin: 0 0 0.1em 0; color: var(--text); letter-spacing: -0.03em; } -.stat small { font-size: 0.8em; font-weight: 500; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; } +.stat h2 { font-size: 24px; font-weight: 800; margin: 0 0 4px; color: var(--ink); letter-spacing: -0.02em; text-transform: none; } +.stat small { font-size: 12.8px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; } /* Breadcrumbs */ .breadcrumb { display: flex; align-items: center; gap: 0.4em; font-size: 0.88em; color: var(--text-muted); margin-bottom: 1em; } diff --git a/internal/http/static/list.js b/internal/http/static/list.js new file mode 100644 index 0000000..2b57ff8 --- /dev/null +++ b/internal/http/static/list.js @@ -0,0 +1,56 @@ +// Filter (search + status chips) and sort for [data-list] containers holding . +(function () { + function initList(root) { + var rows = [].slice.call(root.querySelectorAll('tr[data-name]')); + var search = root.querySelector('[data-list-search]'); + var chips = [].slice.call(root.querySelectorAll('[data-chip]')); + var count = root.querySelector('[data-list-count]'); + var reset = root.querySelector('[data-list-reset]'); + var empty = root.querySelector('[data-list-empty]'); + var q = '', status = ''; + + function apply() { + var shown = 0; + rows.forEach(function (r) { + var hit = (!q || r.getAttribute('data-search').indexOf(q) > -1) && (!status || r.getAttribute('data-status') === status); + r.hidden = !hit; + if (hit) shown++; + }); + [].forEach.call(root.querySelectorAll('[data-group]'), function (g) { + g.hidden = !g.querySelector('tr[data-name]:not([hidden])'); + }); + count.textContent = shown + ' of ' + rows.length; + reset.hidden = !(q || status); + if (empty) empty.hidden = shown > 0; + chips.forEach(function (c) { c.setAttribute('aria-pressed', String(c.getAttribute('data-chip') === status)); }); + } + + if (search) search.addEventListener('input', function () { q = search.value.trim().toLowerCase(); apply(); }); + chips.forEach(function (c) { c.addEventListener('click', function () { status = c.getAttribute('data-chip'); apply(); }); }); + reset.addEventListener('click', function () { q = ''; status = ''; if (search) search.value = ''; apply(); }); + + [].forEach.call(root.querySelectorAll('table.rows'), function (table) { + var buttons = [].slice.call(table.querySelectorAll('.th-sort')); + var key = null, dir = 'asc'; + buttons.forEach(function (btn) { + btn.addEventListener('click', function () { + var k = btn.getAttribute('data-sort'), num = btn.getAttribute('data-type') === 'number'; + dir = key === k ? (dir === 'asc' ? 'desc' : 'asc') : (num ? 'desc' : 'asc'); + key = k; + buttons.forEach(function (b) { b.parentNode.setAttribute('aria-sort', 'none'); }); + btn.parentNode.setAttribute('aria-sort', dir === 'asc' ? 'ascending' : 'descending'); + var attr = 'data-' + k, body = table.tBodies[0]; + [].slice.call(body.rows).sort(function (a, b) { + var x = a.getAttribute(attr) || '', y = b.getAttribute(attr) || ''; + var r = num ? (parseFloat(x) || 0) - (parseFloat(y) || 0) : x.localeCompare(y); + return dir === 'asc' ? r : -r; + }).forEach(function (r) { body.appendChild(r); }); + }); + }); + }); + apply(); + } + document.addEventListener('DOMContentLoaded', function () { + [].forEach.call(document.querySelectorAll('[data-list]'), initList); + }); +})(); diff --git a/internal/http/templates.go b/internal/http/templates.go index b703076..4345626 100644 --- a/internal/http/templates.go +++ b/internal/http/templates.go @@ -213,6 +213,7 @@ var funcMap = template.FuncMap{ "div": div, "mul": mul, "clamp100": clamp100, + "list": func(v ...string) []string { return v }, "dict": func(kv ...interface{}) map[string]interface{} { m := make(map[string]interface{}, len(kv)/2) for i := 0; i+1 < len(kv); i += 2 { diff --git a/internal/http/templates/apps_list.html b/internal/http/templates/apps_list.html index 99b94ad..5eab69c 100644 --- a/internal/http/templates/apps_list.html +++ b/internal/http/templates/apps_list.html @@ -1,151 +1,52 @@ {{define "apps_list"}} {{if .Apps}} - +
+{{template "ui_listbar" (dict "Placeholder" "Search name or domain" "Chips" (list "running" "stopped" "draft" "failed"))}} {{range .ServerGroups}} -
-

- {{if .ServerID}}{{.ServerName}}{{else}}{{.ServerName}}{{end}} - {{if .Host}}{{.Host}}{{end}} - {{if .Status}}{{.Status}}{{end}} - — {{len .Apps}} app(s) -

-
- - - - - - - - - - - - - {{range .Apps}} - - - - - - - - - - {{end}} - -
Name Status CPU Memory Disk Domain Port
{{.Name}}{{.Status}}{{with $st := index $.AppStats .ID}}{{printf "%.1f" (clamp100 $st.CPUPercent)}}%{{else}}-{{end}}{{with $st := index $.AppStats .ID}}{{formatBytes $st.MemUsageBytes}}{{else}}-{{end}}{{with $st := index $.AppStats .ID}}{{formatBytes $st.DiskUsageBytes}}{{else}}-{{end}}{{if .Domain}}{{.Domain}}{{else}}-{{end}}{{.Port}}
- +
+
+

{{if .ServerID}}{{.ServerName}}{{else}}{{.ServerName}}{{end}}

+ {{if .Host}}{{.Host}}{{end}} + {{if .Status}}{{.Status}}{{end}} + {{len .Apps}} {{if eq (len .Apps) 1}}app{{else}}apps{{end}} +
+ + + + + + + + + + + + + {{range .Apps}} + + + + + + + + + {{end}} + +
{{.Name}}{{if .Domain}}{{.Domain}}{{end}}{{if eq .Status "running"}}{{end}}{{.Status}}{{with $st := index $.AppStats .ID}}{{printf "%.1f" (clamp100 $st.CPUPercent)}}%{{else}}-{{end}}{{with $st := index $.AppStats .ID}}{{formatBytes $st.MemUsageBytes}}{{else}}-{{end}}{{with $st := index $.AppStats .ID}}{{formatBytes $st.DiskUsageBytes}}{{else}}-{{end}}{{.Port}}
+
{{end}} - - {{else}}
+ {{template "ui_icon" "box"}}

No apps deployed yet.

- Deploy Your First App + {{if eq .Nav "dashboard"}}Deploy Your First App{{end}}
{{end}} {{end}} diff --git a/internal/http/templates/dashboard.html b/internal/http/templates/dashboard.html index 26f4959..5f3dab6 100644 --- a/internal/http/templates/dashboard.html +++ b/internal/http/templates/dashboard.html @@ -10,30 +10,18 @@

Dashboard

{{if .Stats}} -
-
-

{{.Stats.TotalServers}}

- Servers -
-
-

{{.Stats.OnlineServers}}

- Online Servers -
-
-

{{.Stats.TotalApps}}

- Apps -
-
-

{{.Stats.RunningApps}}

- Running Apps -
+
+

{{.Stats.TotalServers}}

Servers
+

{{.Stats.OnlineServers}}

Online servers
+

{{.Stats.TotalApps}}

Apps
+

{{.Stats.RunningApps}}

Running apps
{{end}} -

Servers ({{len .Servers}})

+

Servers

{{len .Servers}}View all
{{template "servers_list" .}} -

Apps ({{len .Apps}})

+

Apps

{{len .Apps}}View all
{{template "apps_list" .}}
{{template "scripts" .}} diff --git a/internal/http/templates/layout.html b/internal/http/templates/layout.html index 854a419..7ba4bbd 100644 --- a/internal/http/templates/layout.html +++ b/internal/http/templates/layout.html @@ -50,6 +50,7 @@ {{template "theme_head" .}} + diff --git a/internal/http/templates/servers_detail.html b/internal/http/templates/servers_detail.html index b73a1b1..b9df026 100644 --- a/internal/http/templates/servers_detail.html +++ b/internal/http/templates/servers_detail.html @@ -130,9 +130,28 @@

Worker Access

{{template "ui_sheet_end"}} {{template "ui_confirm" (dict "ID" "server-init" "Title" (printf "Init worker %s?" .Server.Name) "Body" "This re-runs the worker setup over SSH and may restart Docker and Caddy on this server." "Label" "Init worker" "Action" (printf "servers/%d/init" .Server.ID))}} {{template "ui_confirm" (dict "ID" "server-remove" "Title" (printf "Remove %s?" .Server.Name) "Body" "This removes the server from Dockify and deletes its SSH key. It cannot be undone. Undeploy or move its apps first." "Label" "Remove server" "Action" (printf "servers/%d/delete" .Server.ID))}} - {{range .Apps}}{{if ne .Status "draft"}}{{template "ui_confirm" (dict "ID" (printf "undeploy-%d" .ID) "Title" (printf "Undeploy %s?" .Name) "Body" (printf "This stops and removes the app from %s." $.Server.Name) "Label" "Undeploy" "Action" (printf "apps/%d/undeploy" .ID))}}{{else}}{{template "ui_confirm" (dict "ID" (printf "undeploy-%d" .ID) "Title" (printf "Delete draft %s?" .Name) "Body" "This permanently deletes the draft." "Label" "Delete draft" "Action" (printf "apps/%d/undeploy" .ID))}}{{end}}{{end}} + {{template "ui_confirm" (dict "ID" "undeploy-confirm" "Title" "Undeploy?" "Body" "" "Label" "Undeploy")}} + {{else}}
+ {{template "ui_icon" "server"}}

No servers registered yet.

- Add Your First Server + {{if eq .Nav "dashboard"}}Add Your First Server{{end}}
{{end}} {{end}} diff --git a/internal/http/templates/ui_listbar.html b/internal/http/templates/ui_listbar.html new file mode 100644 index 0000000..8bc5430 --- /dev/null +++ b/internal/http/templates/ui_listbar.html @@ -0,0 +1,6 @@ +{{/* Search + status chips + "N of M" meta for a [data-list] container (static/list.js). dict: Placeholder, Chips. */}} +{{define "ui_listbar"}}
+ +
{{range .Chips}}{{end}}
+

+
{{end}}