diff --git a/.gitignore b/.gitignore index 06db909..ff0a273 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ vendor/ # Templ generated *_templ.go .claude/settings.json +.gstack/ diff --git a/internal/api/render.go b/internal/api/render.go index fc41971..d07b7f5 100644 --- a/internal/api/render.go +++ b/internal/api/render.go @@ -33,14 +33,22 @@ func dashboardPage(hosts []model.Host) string { func hostTile(h model.Host) string { stateClass := stateColor(h.State) + led := ledClass(h.State) return fmt.Sprintf(` -
%s
-
%s
-
%s
-
%s
+
+ + %s +
+
+ %s +
+
- `, h.ID, stateClass, h.ID, html.EscapeString(h.Hostname), html.EscapeString(h.ServerType), h.State, h.MAC) + `, h.ID, stateClass, h.ID, led, html.EscapeString(h.Hostname), html.EscapeString(h.ServerType), h.MAC, h.State) } func hostFormPage(types []string, errMsg string, prefill *model.Host) string { @@ -77,11 +85,12 @@ func hostFormPage(types []string, errMsg string, prefill *model.Host) string { func hostDetailPage(h *model.Host, ops []model.Operation) string { stateClass := stateColor(h.State) + led := ledClass(h.State) canRebuild := h.State == model.StateRegistered || h.State == model.StateReady || h.State == model.StateFailed var actions strings.Builder if canRebuild { - actions.WriteString(fmt.Sprintf(`
`, h.ID)) + actions.WriteString(fmt.Sprintf(`
`, h.ID)) } actions.WriteString(fmt.Sprintf(`
`, h.ID)) @@ -106,22 +115,27 @@ func hostDetailPage(h *model.Host, ops []model.Operation) string { return layout(h.Hostname, fmt.Sprintf(`
-

%s

+ +

%s

%s
- - - - - -
MAC%s
Server Type%s
IP Address%s
Notes%s
-
%s
+
+ + + + + +
MAC%s
Server Type%s
IP Address%s
Notes%s
+
%s
+

Operations

- - - %s -
KindStateStartedDurationError
- `, html.EscapeString(h.Hostname), stateClass, h.State, h.MAC, h.ServerType, ip, html.EscapeString(h.Notes), actions.String(), opsHTML.String())) +
+ + + %s +
KindStateStartedDurationError
+
+ `, led, html.EscapeString(h.Hostname), stateClass, h.State, h.MAC, h.ServerType, ip, html.EscapeString(h.Notes), actions.String(), opsHTML.String())) } func imagesPage(images []model.Image) string { @@ -131,24 +145,26 @@ func imagesPage(images []model.Image) string { if img.IsDefault { def = `default` } else { - def = fmt.Sprintf(`
`, img.ID) + def = fmt.Sprintf(`
`, img.ID) } - deleteBtn := fmt.Sprintf(`
`, img.ID, html.EscapeString(img.Name)) + deleteBtn := fmt.Sprintf(`
`, img.ID, html.EscapeString(img.Name)) rows.WriteString(fmt.Sprintf(`%s%s%s%s%s%s`, html.EscapeString(img.Name), img.Kind, img.Version, def, img.CreatedAt.Format("2006-01-02"), deleteBtn)) } if len(images) == 0 { - rows.WriteString(`No images uploaded yet.`) + rows.WriteString(`No images uploaded yet.`) } return layout("Images", fmt.Sprintf(`
Upload Image %d images
- - - %s -
NameKindVersionDefaultAdded
+
+ + + %s +
NameKindVersionDefaultAdded
+
`, len(images), rows.String())) } @@ -200,6 +216,23 @@ func stateColor(s model.HostState) string { } } +func ledClass(s model.HostState) string { + switch s { + case model.StateRegistered: + return "led-grey" + case model.StatePXEReady, model.StatePXEBooted, model.StateInstalling: + return "led-blue" + case model.StateInstalled, model.StateFirstBoot, model.StateJoining: + return "led-amber" + case model.StateReady: + return "led-green" + case model.StateFailed: + return "led-red" + default: + return "led-grey" + } +} + func layout(title, body string) string { return fmt.Sprintf(` @@ -207,6 +240,9 @@ func layout(title, body string) string { %s — Provisioning + + + @@ -216,7 +252,10 @@ func layout(title, body string) string { Dashboard Images - +
+ Link + +
%s
diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 80d078b..e26ed06 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -1,138 +1,382 @@ +/* === RESET === */ * { box-sizing: border-box; margin: 0; padding: 0; } +/* === CUSTOM PROPERTIES === */ :root { - --bg: #0f1419; - --surface: #1a2027; - --border: #2d3748; - --text: #e2e8f0; - --text-muted: #8892a4; - --accent: #60a5fa; - --green: #34d399; - --amber: #fbbf24; - --red: #f87171; - --blue: #60a5fa; + --bg: #f0f2f5; + --card: #ffffff; + --nav: #0c1222; + --nav-text: #94a3b8; + --nav-text-hover: #ffffff; + --border: #e0e4ea; + --border-focus: #2563eb; + --text: #1a2233; + --text-secondary: #5f6d7e; + --text-tertiary: #94a3b8; + --accent: #2563eb; + --accent-hover: #1d4fd8; + --accent-subtle: #eff4ff; + --green: #16a34a; + --green-bg: #f0fdf4; + --green-border: #bbf7d0; + --amber: #d97706; + --amber-bg: #fffbeb; + --amber-border: #fde68a; + --red: #dc2626; + --red-bg: #fef2f2; + --red-border: #fecaca; + --blue: #2563eb; + --blue-bg: #eff6ff; + --blue-border: #bfdbfe; + --grey: #6b7280; + --grey-bg: #f3f4f6; + --grey-border: #e5e7eb; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-md: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.06), 0 1px 3px rgba(0, 0, 0, 0.04); + --radius: 8px; + --radius-sm: 6px; + --font: "Outfit", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: "IBM Plex Mono", "SF Mono", "Consolas", monospace; } +/* === GLOBAL === */ body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + font-family: var(--font); background: var(--bg); color: var(--text); line-height: 1.5; + font-size: 0.95rem; + -webkit-font-smoothing: antialiased; } +h2 { + font-weight: 600; + font-size: 1.35rem; + color: var(--text); + margin-bottom: 1.5rem; + letter-spacing: -0.01em; +} + +h3 { + font-weight: 600; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-tertiary); + margin: 2rem 0 0.75rem; +} + +/* === TOPBAR === */ .topbar { display: flex; align-items: center; - gap: 1.5rem; - padding: 0.75rem 1.5rem; - background: var(--surface); - border-bottom: 1px solid var(--border); + gap: 2rem; + padding: 0 1.5rem; + height: 60px; + background: var(--nav); } .brand { font-weight: 700; - font-size: 1.1rem; - color: var(--accent); + font-size: 1.05rem; + color: #ffffff; text-decoration: none; + letter-spacing: -0.01em; } -.nav-links { display: flex; gap: 1rem; } -.nav-links a { color: var(--text-muted); text-decoration: none; font-size: 0.9rem; } -.nav-links a:hover { color: var(--text); } +.nav-links { display: flex; gap: 0.25rem; } +.nav-links a { + color: var(--nav-text); + text-decoration: none; + font-size: 0.9rem; + font-weight: 500; + padding: 0.375rem 0.75rem; + border-radius: var(--radius-sm); + transition: color 0.15s, background 0.15s; +} +.nav-links a:hover { + color: var(--nav-text-hover); + background: rgba(255, 255, 255, 0.08); +} -.sse-indicator { margin-left: auto; color: var(--green); font-size: 0.8rem; } -.sse-indicator.disconnected { color: var(--red); } +.sse-status { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.375rem; +} +.sse-label { + font-size: 0.7rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--nav-text); +} -main { padding: 1.5rem; max-width: 1200px; margin: 0 auto; } - -.actions { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; } -.count { color: var(--text-muted); font-size: 0.85rem; } - -.btn { +/* === LED === */ +.led { display: inline-block; - padding: 0.5rem 1rem; + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; +} +.led-green { color: var(--green); } +.led-amber { color: var(--amber); animation: pulse 2s ease-in-out infinite; } +.led-red { color: var(--red); animation: pulse 1.5s ease-in-out infinite; } +.led-blue { color: var(--blue); animation: pulse 2.5s ease-in-out infinite; } +.led-grey { color: var(--grey); } + +.led-lg { width: 9px; height: 9px; } + +/* Navbar LED uses lighter colors */ +.topbar .led-green { color: #4ade80; } +.topbar .led-red { color: #f87171; } +.topbar .led-grey { color: var(--nav-text); } + +/* === LAYOUT === */ +main { + padding: 2rem; + max-width: 1200px; + margin: 0 auto; +} + +/* === BUTTONS === */ +.btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 1.1rem; background: var(--accent); - color: #000; + color: #ffffff; border: none; - border-radius: 4px; + border-radius: var(--radius-sm); cursor: pointer; text-decoration: none; - font-size: 0.85rem; + font-size: 0.875rem; + font-family: var(--font); + font-weight: 500; + transition: background 0.15s, box-shadow 0.15s; + box-shadow: var(--shadow-sm); +} +.btn:hover { + background: var(--accent-hover); + box-shadow: var(--shadow-md); +} +.btn-danger { + background: var(--red); +} +.btn-danger:hover { + background: #b91c1c; +} +.btn-sm { + font-size: 0.75rem; + padding: 0.3rem 0.6rem; +} + +/* === ACTIONS === */ +.actions { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1.5rem; +} +.count { + color: var(--text-tertiary); + font-size: 0.8rem; font-weight: 500; } -.btn:hover { opacity: 0.9; } -.btn-danger { background: var(--red); } -.host-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; } +/* === HOST GRID === */ +.host-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1rem; +} .tile { display: block; - padding: 1rem; - background: var(--surface); + padding: 1.25rem 1.5rem; + background: var(--card); border: 1px solid var(--border); - border-radius: 6px; + border-radius: var(--radius); text-decoration: none; color: var(--text); - transition: border-color 0.15s; + transition: border-color 0.15s, box-shadow 0.2s; + box-shadow: var(--shadow-sm); +} +.tile:hover { + border-color: var(--accent); + box-shadow: var(--shadow-lg); } -.tile:hover { border-color: var(--accent); } -.tile-name { font-weight: 600; margin-bottom: 0.25rem; } -.tile-type { font-size: 0.8rem; color: var(--text-muted); } -.tile-state { font-size: 0.8rem; margin-top: 0.5rem; padding: 0.15rem 0.5rem; border-radius: 3px; display: inline-block; } -.tile-mac { font-size: 0.75rem; color: var(--text-muted); margin-top: 0.5rem; font-family: monospace; } -.state-grey .tile-state { background: #374151; color: #9ca3af; } -.state-blue .tile-state { background: #1e3a5f; color: var(--blue); } -.state-amber .tile-state { background: #422006; color: var(--amber); } -.state-green .tile-state { background: #064e3b; color: var(--green); } -.state-red .tile-state { background: #450a0a; color: var(--red); } +.tile-header { + display: flex; + align-items: center; + gap: 0.625rem; + margin-bottom: 0.5rem; +} +.tile-name { + font-weight: 600; + font-size: 1rem; + color: var(--text); + font-family: var(--font-mono); +} -.host-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; } -.badge { padding: 0.2rem 0.6rem; border-radius: 3px; font-size: 0.8rem; } -.state-grey .badge, .badge.state-grey { background: #374151; color: #9ca3af; } -.state-blue .badge, .badge.state-blue { background: #1e3a5f; color: var(--blue); } -.state-amber .badge, .badge.state-amber { background: #422006; color: var(--amber); } -.state-green .badge, .badge.state-green { background: #064e3b; color: var(--green); } -.state-red .badge, .badge.state-red { background: #450a0a; color: var(--red); } +.tile-meta { margin-bottom: 0.75rem; } +.tile-type { + font-size: 0.85rem; + color: var(--text-secondary); +} +.tile-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 0.75rem; + border-top: 1px solid var(--border); +} +.tile-mac { + font-size: 0.8rem; + font-family: var(--font-mono); + color: var(--text-tertiary); +} + +/* === STATUS BADGES === */ +.tile-state-label, .badge { + display: inline-flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 0.2rem 0.5rem; + border-radius: 4px; +} + +.state-grey .tile-state-label, .badge.state-grey { background: var(--grey-bg); color: var(--grey); border: 1px solid var(--grey-border); } +.state-blue .tile-state-label, .badge.state-blue { background: var(--blue-bg); color: var(--blue); border: 1px solid var(--blue-border); } +.state-amber .tile-state-label, .badge.state-amber { background: var(--amber-bg); color: var(--amber); border: 1px solid var(--amber-border); } +.state-green .tile-state-label, .badge.state-green { background: var(--green-bg); color: var(--green); border: 1px solid var(--green-border); } +.state-red .tile-state-label, .badge.state-red { background: var(--red-bg); color: var(--red); border: 1px solid var(--red-border); } + +/* === PANELS === */ +.panel { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.5rem; + box-shadow: var(--shadow-sm); + margin-bottom: 1rem; +} + +/* === HOST DETAIL === */ +.host-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1.5rem; +} +.host-header h2 { margin-bottom: 0; } + +/* === TABLES === */ .detail-table { margin-bottom: 1.5rem; } -.detail-table th { text-align: left; padding: 0.4rem 1rem 0.4rem 0; color: var(--text-muted); font-weight: 500; } -.detail-table td { padding: 0.4rem 0; } +.detail-table th { + text-align: left; + padding: 0.5rem 1.5rem 0.5rem 0; + color: var(--text-tertiary); + font-weight: 500; + font-size: 0.825rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.detail-table td { + padding: 0.5rem 0; + color: var(--text); + font-family: var(--font-mono); + font-size: 0.9rem; +} -.ops-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; } -.ops-table th { text-align: left; padding: 0.5rem; border-bottom: 1px solid var(--border); color: var(--text-muted); } -.ops-table td { padding: 0.5rem; border-bottom: 1px solid var(--border); } +.ops-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; } +.ops-table th { + text-align: left; + padding: 0.625rem 0.75rem; + border-bottom: 2px solid var(--border); + color: var(--text-tertiary); + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.ops-table td { + padding: 0.625rem 0.75rem; + border-bottom: 1px solid var(--border); + color: var(--text); +} +.ops-table tr:hover td { background: var(--accent-subtle); } -.form { max-width: 400px; } -.form label { display: block; margin-bottom: 1rem; color: var(--text-muted); font-size: 0.85rem; } +/* === FORMS === */ +.form { max-width: 420px; } +.form label { + display: block; + margin-bottom: 1.25rem; + color: var(--text-secondary); + font-size: 0.85rem; + font-weight: 500; +} .form input, .form select, .form textarea { display: block; width: 100%; - padding: 0.5rem; - margin-top: 0.25rem; - background: var(--bg); + padding: 0.55rem 0.75rem; + margin-top: 0.375rem; + background: var(--card); border: 1px solid var(--border); - border-radius: 4px; + border-radius: var(--radius-sm); color: var(--text); + font-family: var(--font-mono); font-size: 0.9rem; + transition: border-color 0.15s, box-shadow 0.15s; } -.form textarea { min-height: 60px; resize: vertical; } +.form input:focus, .form select:focus, .form textarea:focus { + outline: none; + border-color: var(--border-focus); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} +.form select { font-family: var(--font); } +.form textarea { min-height: 80px; resize: vertical; } .form .btn { margin-top: 0.5rem; } -.error { background: #450a0a; color: var(--red); padding: 0.75rem; border-radius: 4px; margin-bottom: 1rem; font-size: 0.85rem; } -.empty { color: var(--text-muted); padding: 2rem; text-align: center; } -.empty a { color: var(--accent); } -.inline { display: inline; } -h2 { margin-bottom: 1rem; } -h3 { margin: 1.5rem 0 0.75rem; color: var(--text-muted); font-size: 0.95rem; } +/* === ERROR === */ +.error { + background: var(--red-bg); + color: var(--red); + padding: 0.75rem 1rem; + border: 1px solid var(--red-border); + border-radius: var(--radius-sm); + margin-bottom: 1rem; + font-size: 0.825rem; + font-weight: 500; +} -.upload-progress { max-width: 400px; } +/* === EMPTY STATE === */ +.empty { + color: var(--text-tertiary); + padding: 3rem; + text-align: center; +} +.empty a { color: var(--accent); text-decoration: none; font-weight: 500; } +.empty a:hover { text-decoration: underline; } + +/* === UPLOAD PROGRESS === */ +.upload-progress { max-width: 420px; } .progress-bar-track { width: 100%; - height: 8px; + height: 6px; background: var(--bg); - border: 1px solid var(--border); - border-radius: 4px; + border-radius: 3px; margin: 0.75rem 0; overflow: hidden; } @@ -140,9 +384,25 @@ h3 { margin: 1.5rem 0 0.75rem; color: var(--text-muted); font-size: 0.95rem; } height: 100%; width: 0%; background: var(--accent); - border-radius: 4px; + border-radius: 3px; transition: width 0.3s ease; } .progress-bar-fill.complete { background: var(--green); } -.progress-text { font-size: 0.85rem; color: var(--text); margin-bottom: 0.25rem; } -.progress-detail { font-size: 0.8rem; color: var(--text-muted); } +.progress-text { font-size: 0.825rem; color: var(--text); font-weight: 500; } +.progress-detail { font-size: 0.775rem; color: var(--text-secondary); } + +/* === UTILITY === */ +.inline { display: inline; } + +/* === ANIMATIONS === */ +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* === RESPONSIVE === */ +@media (max-width: 720px) { + .host-grid { grid-template-columns: 1fr; } + main { padding: 1.25rem; } + .topbar { padding: 0 1rem; gap: 1rem; } +} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 743d617..c417ff5 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -5,13 +5,13 @@ function connect() { es = new EventSource('/events'); es.addEventListener('hello', function() { - dot.classList.remove('disconnected'); + dot.className = 'led led-green'; }); es.addEventListener('host.state_changed', function() { window.location.reload(); }); es.onerror = function() { - dot.classList.add('disconnected'); + dot.className = 'led led-red'; es.close(); setTimeout(connect, 3000); };