diff --git a/CHANGELOG.md b/CHANGELOG.md index 736aa42..5d480aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ follows [SemVer](https://semver.org/). ## [Unreleased] +### Added +- **Projects** — a named grouping of Servers as an organizational lens + over the Fleet. A Server belongs to at most one Project; Servers + without a Project render as plain cards on the Fleet page (no + "Unassigned" pseudo-group). Manage from **Settings → Projects** (list, + inline create, rename, recolor, delete). Assign at server creation + time via the Add Server modal (with inline "Add new project") or from + a Server's settings tab. Server detail shows a color-tinted Project + badge near the title. +- Audit events: `project.created`, `project.renamed`, `project.recolored`, + `project.deleted`, `server.project_assigned`, `server.project_unassigned`. + Deleting a Project cascade-unassigns its Servers (preserved with + `project_id` nulled) and emits one event per affected Server in the + same transaction. +- New UI helpers `ui_project_group_header` and `ui_project_badge` + (`MastWeb.Components.UI.Domain`), with previews on `/dev/ui`. + ## [0.6.0] - 2026-05-22 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index fdcfffe..c9c6be3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,6 +49,16 @@ We have a `./components.pen` file with the current UI design. When making ui changes, check the pencil mcp to see if the design is already there. If not, STOP and ask for an update before implementing. This keeps the code and design in sync. +**Responsiveness is non-negotiable for new components.** Any new helper +added to `lib/mast_web/components/ui/*` must work across the full +breakpoint scale on day one (Tailwind `sm:` / `md:` / `lg:` prefixes on +layout, padding, font sizes, column counts; `flex-wrap` or +`grid-cols-1 md:grid-cols-N` instead of fixed widths; `min-w-0 truncate` +for text in flex children; ≥40px tap targets). Then add the component to +`/dev/ui` so it can be previewed at multiple widths. Shipping a fixed- +width component is a sweep waiting to happen — we've already done that +sweep twice. + ## Architecture decisions diff --git a/assets/package-lock.json b/assets/package-lock.json index df6d7aa..4a3402e 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -1,13 +1,12 @@ { - "name": "assets", - "version": "1.0.0", + "name": "mast-assets", + "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "assets", - "version": "1.0.0", - "license": "ISC", + "name": "mast-assets", + "version": "0.0.0", "dependencies": { "chart.js": "^4.5.1", "chartjs-adapter-date-fns": "^3.0.0", diff --git a/components.pen b/components.pen index 94b730a..d231857 100644 --- a/components.pen +++ b/components.pen @@ -1464,7 +1464,7 @@ "id": "ZKPgC", "name": "elixirLabel", "fill": "$--font-tertiary", - "content": "Elixir App Card", + "content": "RELEASE CARD", "fontFamily": "$--font-family", "fontSize": 11, "fontWeight": "600", @@ -1473,7 +1473,7 @@ { "type": "frame", "id": "t0WPo", - "name": "Card/ElixirApp", + "name": "Card/Release", "reusable": true, "width": "fill_container", "fill": "$--bg-card", @@ -1734,6 +1734,77 @@ "fontWeight": "normal" } ] + }, + { + "type": "text", + "id": "TSk2g", + "name": "compLabel", + "fill": "$--font-tertiary", + "content": "PROJECT GROUP HEADER", + "fontFamily": "$--font-family", + "fontSize": 11, + "fontWeight": "600", + "letterSpacing": 1.2 + }, + { + "type": "frame", + "id": "E9Fv2b", + "name": "ProjectGroup/Header", + "reusable": true, + "width": "fill_container", + "gap": 8, + "padding": [ + 4, + 0 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "R5qbm", + "name": "chevron", + "width": 16, + "height": 16, + "iconFontName": "chevron-down", + "iconFontFamily": "lucide", + "fill": "$--font-secondary" + }, + { + "type": "ellipse", + "id": "azbqM", + "name": "dot", + "fill": "#3B82F6", + "width": 10, + "height": 10 + }, + { + "type": "text", + "id": "c8QBh", + "name": "projName", + "fill": "$--font-primary", + "content": "Project", + "fontFamily": "$--font-family", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "T7Y8hg", + "name": "spacer", + "width": "fill_container", + "height": 1 + }, + { + "type": "text", + "id": "k9t2p", + "name": "count", + "fill": "$--font-secondary", + "content": "0 servers", + "fontFamily": "$--font-family", + "fontSize": 12, + "fontWeight": "normal" + } + ] } ] }, @@ -2340,7 +2411,7 @@ "id": "jbsSP", "name": "tabInactive1Text", "fill": "$--font-secondary", - "content": "Apps", + "content": "Releases", "fontFamily": "$--font-family", "fontSize": 14, "fontWeight": "normal" @@ -3493,7 +3564,7 @@ { "type": "frame", "id": "t388i", - "name": "Stat Apps", + "name": "Stat Releases", "width": "fill_container", "fill": "$--bg-card", "cornerRadius": "$--radius-lg", @@ -3529,7 +3600,7 @@ "id": "q49pga", "name": "stat4Label", "fill": "$--font-secondary", - "content": "Elixir Apps", + "content": "Releases", "fontFamily": "$--font-family", "fontSize": 13, "fontWeight": "normal" @@ -5840,7 +5911,7 @@ { "type": "frame", "id": "UO9me", - "name": "tab2", + "name": "tabReleases", "padding": [ 10, 16 @@ -5851,7 +5922,7 @@ "id": "K9KB2z", "name": "tab2Txt", "fill": "$--font-secondary", - "content": "Apps", + "content": "Releases", "fontFamily": "$--font-family", "fontSize": 13, "fontWeight": "normal" @@ -8427,7 +8498,7 @@ { "type": "frame", "id": "K8lXwe", - "name": "t3apps", + "name": "t3releases", "padding": [ 10, 16 @@ -8438,7 +8509,7 @@ "id": "enTjq", "name": "t3appsTxt", "fill": "$--font-secondary", - "content": "Apps", + "content": "Releases", "fontFamily": "$--font-family", "fontSize": 13, "fontWeight": "normal" @@ -15687,7 +15758,7 @@ "id": "ySHdr", "x": 3488, "y": 2222, - "name": "Server Detail - Apps Tab", + "name": "Server Detail - Releases Tab", "clip": true, "width": 1440, "height": 900, @@ -16191,7 +16262,7 @@ { "type": "frame", "id": "Bijhe", - "name": "tApps", + "name": "tReleases", "stroke": { "align": "inside", "thickness": { @@ -16207,9 +16278,9 @@ { "type": "text", "id": "Z048k", - "name": "tAppsT", + "name": "tReleasesT", "fill": "$--accent", - "content": "Apps", + "content": "Releases", "fontFamily": "$--font-family", "fontSize": 13, "fontWeight": "500" @@ -17808,7 +17879,7 @@ { "type": "frame", "id": "ex19j", - "name": "tabApps", + "name": "tabReleases", "padding": [ 10, 16 @@ -17819,7 +17890,7 @@ "id": "G0AZ6a", "name": "tabAppsTxt", "fill": "$--font-tertiary", - "content": "Apps", + "content": "Releases", "fontFamily": "$--font-family", "fontSize": 13, "fontWeight": "normal" @@ -18426,7 +18497,7 @@ "id": "v24vn", "x": 0, "y": 4410, - "name": "App Detail - hermes", + "name": "Release Detail - hermes", "clip": true, "width": 1440, "height": 900, @@ -26584,6 +26655,1673 @@ ] } ] + }, + { + "type": "frame", + "id": "wzB2j", + "x": 0, + "y": 8949, + "name": "Fleet Overview - Projects", + "width": 1440, + "height": 1000, + "fill": "$--bg-primary", + "children": [ + { + "type": "frame", + "id": "xEWpr", + "name": "sidebar", + "width": 220, + "height": "fill_container", + "fill": "$--bg-sidebar", + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + }, + "fill": "$--border" + }, + "layout": "vertical", + "padding": [ + 20, + 16 + ], + "children": [ + { + "type": "frame", + "id": "z5NP22", + "name": "Logo Row", + "width": "fill_container", + "gap": 10, + "padding": [ + 0, + 0, + 24, + 0 + ], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "hkbQF", + "name": "logoIcon", + "width": 32, + "height": 32, + "fill": "$--accent", + "cornerRadius": "$--radius-md", + "layout": "none", + "children": [ + { + "type": "text", + "id": "iGtWx", + "x": 0, + "y": 0, + "name": "logoLetter", + "fill": "$--accent-text", + "textGrowth": "fixed-width-height", + "width": 32, + "height": 32, + "content": "M", + "textAlign": "center", + "textAlignVertical": "middle", + "fontFamily": "$--font-family", + "fontSize": 18, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "Y7QCza", + "name": "logoText", + "fill": "$--font-primary", + "content": "Mast", + "fontFamily": "$--font-family", + "fontSize": 20, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "sWpf2", + "name": "Nav Items", + "width": "fill_container", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "frame", + "id": "e7gLC", + "name": "Nav Active", + "width": "fill_container", + "fill": "$--accent-muted", + "cornerRadius": "$--radius-md", + "gap": 10, + "padding": [ + 10, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "lAyBi", + "name": "navActiveIcon", + "width": 18, + "height": 18, + "iconFontName": "layout-dashboard", + "iconFontFamily": "lucide", + "fill": "$--accent" + }, + { + "type": "text", + "id": "aPZ3I", + "name": "navActiveLabel", + "fill": "$--accent", + "content": "Dashboard", + "fontFamily": "$--font-family", + "fontSize": 14, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "SyNGa", + "name": "Nav Servers", + "width": "fill_container", + "cornerRadius": "$--radius-md", + "gap": 10, + "padding": [ + 10, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "s7jULB", + "name": "nav2Icon", + "width": 18, + "height": 18, + "iconFontName": "server", + "iconFontFamily": "lucide", + "fill": "$--font-secondary" + }, + { + "type": "text", + "id": "D1CxeP", + "name": "nav2Label", + "fill": "$--font-secondary", + "content": "Servers", + "fontFamily": "$--font-family", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "qqZGJ", + "name": "Nav Alerts", + "width": "fill_container", + "cornerRadius": "$--radius-md", + "gap": 10, + "padding": [ + 10, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "Q8nPH", + "name": "nav4Icon", + "width": 18, + "height": 18, + "iconFontName": "bell", + "iconFontFamily": "lucide", + "fill": "$--font-secondary" + }, + { + "type": "text", + "id": "f7ANEz", + "name": "nav4Label", + "fill": "$--font-secondary", + "content": "Alerts", + "fontFamily": "$--font-family", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "t2eww", + "name": "Nav Audit", + "width": "fill_container", + "cornerRadius": "$--radius-md", + "gap": 10, + "padding": [ + 10, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "pbfFE", + "width": 18, + "height": 18, + "iconFontName": "scroll-text", + "iconFontFamily": "lucide", + "fill": "$--font-secondary" + }, + { + "type": "text", + "id": "p4jtP", + "fill": "$--font-secondary", + "content": "Audit", + "fontFamily": "$--font-family", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "casun", + "name": "Nav Settings", + "width": "fill_container", + "cornerRadius": "$--radius-md", + "gap": 10, + "padding": [ + 10, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "b9T0cn", + "name": "nav5Icon", + "width": 18, + "height": 18, + "iconFontName": "settings", + "iconFontFamily": "lucide", + "fill": "$--font-secondary" + }, + { + "type": "text", + "id": "viDz8", + "name": "nav5Label", + "fill": "$--font-secondary", + "content": "Settings", + "fontFamily": "$--font-family", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "a3hAjR", + "name": "spacer", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "text", + "id": "BgWoj", + "name": "ver1", + "opacity": 0.6, + "fill": "$--font-tertiary", + "content": "v0.3.0", + "fontFamily": "$--font-mono", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Jd4kE", + "name": "Main Content", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "fill": "$--bg-primary", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "fGj9j", + "name": "header", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "gap": 16, + "padding": [ + 16, + 24 + ], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Vtssq", + "name": "headerTitle", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "g5v8v6", + "name": "headerH1", + "fill": "$--font-primary", + "content": "Fleet Overview", + "fontFamily": "$--font-family", + "fontSize": 20, + "fontWeight": "700" + }, + { + "type": "text", + "id": "iDxoT", + "name": "headerSub", + "fill": "$--font-secondary", + "content": "6 servers · 2 projects", + "fontFamily": "$--font-family", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "CMFUY", + "name": "headerSpacer", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "bQd2T", + "name": "Search", + "width": 240, + "fill": "$--bg-input", + "cornerRadius": "$--radius-md", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "gap": 8, + "padding": [ + 8, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "rtkM7", + "name": "searchIcon", + "width": 16, + "height": 16, + "iconFontName": "search", + "iconFontFamily": "lucide", + "fill": "$--font-tertiary" + }, + { + "type": "text", + "id": "CkeVG", + "name": "searchText", + "fill": "$--font-tertiary", + "content": "Search servers...", + "fontFamily": "$--font-family", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "M6EStw", + "name": "bellWrap", + "width": 36, + "height": 36, + "fill": "$--bg-tertiary", + "cornerRadius": "$--radius-md", + "layout": "none", + "children": [ + { + "type": "icon_font", + "id": "W5ZPD", + "x": 9, + "y": 9, + "name": "bellIcon", + "width": 18, + "height": 18, + "iconFontName": "bell", + "iconFontFamily": "lucide", + "fill": "$--font-secondary" + }, + { + "type": "frame", + "id": "Ufj1g", + "x": 22, + "y": 6, + "name": "bellBadge", + "width": 8, + "height": 8, + "fill": "$--status-online", + "cornerRadius": 4 + } + ] + }, + { + "type": "frame", + "id": "SohKk", + "name": "Add Server Btn", + "fill": "$--accent", + "cornerRadius": "$--radius-md", + "gap": 6, + "padding": [ + 8, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "r6lDqZ", + "name": "addIcon", + "width": 16, + "height": 16, + "iconFontName": "plus", + "iconFontFamily": "lucide", + "fill": "$--accent-text" + }, + { + "type": "text", + "id": "x6Llk7", + "name": "addLabel", + "fill": "$--accent-text", + "content": "Add Server", + "fontFamily": "$--font-family", + "fontSize": 13, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "e4Uire", + "name": "Scroll Content", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 24, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "A2Fss2", + "name": "statsRow", + "width": "fill_container", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "hxWRp", + "name": "Stat Total", + "width": "fill_container", + "fill": "$--bg-card", + "cornerRadius": "$--radius-lg", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "frame", + "id": "Q2zFz", + "name": "stat1Top", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "J8awFr", + "name": "stat1Icon", + "width": 18, + "height": 18, + "iconFontName": "server", + "iconFontFamily": "lucide", + "fill": "$--font-secondary" + }, + { + "type": "text", + "id": "cTkMN", + "name": "stat1Label", + "fill": "$--font-secondary", + "content": "Total Servers", + "fontFamily": "$--font-family", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "TqP3x", + "name": "stat1Num", + "fill": "$--font-primary", + "content": "6", + "fontFamily": "$--font-family", + "fontSize": 32, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "V38SF", + "name": "Stat Online", + "width": "fill_container", + "fill": "$--bg-card", + "cornerRadius": "$--radius-lg", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "frame", + "id": "zWJG0", + "name": "stat2Top", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "MA9eF", + "name": "stat2Icon", + "width": 18, + "height": 18, + "iconFontName": "activity", + "iconFontFamily": "lucide", + "fill": "$--status-online" + }, + { + "type": "text", + "id": "ygTUc", + "name": "stat2Label", + "fill": "$--font-secondary", + "content": "Online", + "fontFamily": "$--font-family", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "FCg3X", + "name": "stat2Num", + "fill": "$--status-online", + "content": "5", + "fontFamily": "$--font-family", + "fontSize": 32, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "c2YorS", + "name": "Stat Updates", + "width": "fill_container", + "fill": "$--bg-card", + "cornerRadius": "$--radius-lg", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "frame", + "id": "uMdOd", + "name": "stat3Top", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "gYxwc", + "name": "stat3Icon", + "width": 18, + "height": 18, + "iconFontName": "download", + "iconFontFamily": "lucide", + "fill": "$--status-warning" + }, + { + "type": "text", + "id": "UcvLd", + "name": "stat3Label", + "fill": "$--font-secondary", + "content": "Updates Available", + "fontFamily": "$--font-family", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "EtYU8", + "name": "stat3Num", + "fill": "$--status-warning", + "content": "12", + "fontFamily": "$--font-family", + "fontSize": 32, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "ACi3m", + "name": "Stat Releases", + "width": "fill_container", + "fill": "$--bg-card", + "cornerRadius": "$--radius-lg", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 12, + "padding": 20, + "children": [ + { + "type": "frame", + "id": "HLt2J", + "name": "stat4Top", + "width": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "FepM2", + "name": "stat4Icon", + "width": 18, + "height": 18, + "iconFontName": "hexagon", + "iconFontFamily": "lucide", + "fill": "$--chart-purple" + }, + { + "type": "text", + "id": "JlDDz", + "name": "stat4Label", + "fill": "$--font-secondary", + "content": "Releases", + "fontFamily": "$--font-family", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "LtcDL", + "name": "stat4Num", + "fill": "$--font-primary", + "content": "9", + "fontFamily": "$--font-family", + "fontSize": 32, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "JPudf", + "name": "Project Group - Blog", + "width": "fill_container", + "layout": "vertical", + "gap": 12, + "children": [ + { + "id": "Utc6h", + "type": "ref", + "ref": "E9Fv2b", + "width": "fill_container", + "name": "blogHeader", + "descendants": { + "azbqM": { + "fill": "#3B82F6" + }, + "c8QBh": { + "content": "Blog" + }, + "k9t2p": { + "content": "2 servers" + } + } + }, + { + "type": "frame", + "id": "d5VCK", + "name": "ServerCardsRow", + "width": "fill_container", + "gap": 16, + "children": [ + { + "type": "frame", + "id": "O8fYz", + "name": "web-prod-1", + "width": "fill_container", + "fill": "$--bg-card", + "cornerRadius": "$--radius-lg", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "bwN2q", + "name": "serverCardHeader", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "UzR04", + "name": "serverCardHeaderLeft", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "z5wduQ", + "name": "serverCardIcon", + "width": 36, + "height": 36, + "fill": "$--accent-muted", + "cornerRadius": "$--radius-md", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "k6mYnK", + "width": 18, + "height": 18, + "iconFontName": "server", + "iconFontFamily": "lucide", + "fill": "$--accent" + } + ] + }, + { + "type": "frame", + "id": "VK9Kp", + "name": "serverCardNameCol", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "q3RPI", + "fill": "$--font-primary", + "content": "web-prod-1", + "fontFamily": "$--font-family", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "WlAmD", + "fill": "$--font-tertiary", + "content": "192.168.1.100", + "fontFamily": "$--font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "id": "tIBK5", + "type": "ref", + "ref": "GhaGB", + "name": "serverCardBadge" + } + ] + }, + { + "type": "frame", + "id": "vWVvc", + "name": "serverCardMeta", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "uT5G0", + "fill": "$--font-secondary", + "content": "Ubuntu 24.04", + "fontFamily": "$--font-family", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "H9zt2l", + "fill": "$--font-tertiary", + "content": "·", + "fontFamily": "$--font-family", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "K7PZs", + "fill": "$--accent", + "content": "99.8% uptime", + "fontFamily": "$--font-family", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "text", + "id": "aVKac", + "fill": "$--font-tertiary", + "content": "·", + "fontFamily": "$--font-family", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "ewRC4", + "fill": "$--font-tertiary", + "content": "2m ago", + "fontFamily": "$--font-family", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "SwoIw", + "name": "serverCardBars", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "D9uKzX", + "name": "cpuBar", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "GnOLV", + "name": "cpuLabel1", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "IbxXV", + "fill": "$--font-tertiary", + "content": "CPU", + "fontFamily": "$--font-family", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "JHL5u", + "fill": "$--font-primary", + "content": "23%", + "fontFamily": "$--font-family", + "fontSize": 11, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "N3ULF", + "name": "cpuTrack1", + "width": "fill_container", + "height": 4, + "fill": "$--bg-tertiary", + "cornerRadius": 2, + "children": [ + { + "type": "rectangle", + "cornerRadius": 2, + "id": "rDgRr", + "fill": "$--chart-blue", + "width": 36, + "height": 4 + } + ] + } + ] + }, + { + "type": "frame", + "id": "QDlqp", + "name": "ramBar", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "os4GX", + "name": "ramLabel1", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "CaT99", + "fill": "$--font-tertiary", + "content": "RAM", + "fontFamily": "$--font-family", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "WJ5Cx", + "fill": "$--font-primary", + "content": "54%", + "fontFamily": "$--font-family", + "fontSize": 11, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "r5bVIj", + "name": "ramTrack1", + "width": "fill_container", + "height": 4, + "fill": "$--bg-tertiary", + "cornerRadius": 2, + "children": [ + { + "type": "rectangle", + "cornerRadius": 2, + "id": "lvL9i", + "fill": "$--chart-orange", + "width": 84, + "height": 4 + } + ] + } + ] + }, + { + "type": "frame", + "id": "kMXst", + "name": "diskBar", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "Uq8YB", + "name": "diskLabel1", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "z4zBcO", + "fill": "$--font-tertiary", + "content": "Disk", + "fontFamily": "$--font-family", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "o1raNO", + "fill": "$--font-primary", + "content": "31%", + "fontFamily": "$--font-family", + "fontSize": 11, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "Oi8An", + "name": "diskTrack1", + "width": "fill_container", + "height": 4, + "fill": "$--bg-tertiary", + "cornerRadius": 2, + "children": [ + { + "type": "rectangle", + "cornerRadius": 2, + "id": "XCQ2h", + "fill": "$--chart-green", + "width": 48, + "height": 4 + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "d3sVYV", + "name": "db-prod-1", + "width": "fill_container", + "fill": "$--bg-card", + "cornerRadius": "$--radius-lg", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "layout": "vertical", + "gap": 12, + "padding": 16, + "children": [ + { + "type": "frame", + "id": "Lfm5f", + "name": "serverCardHeader", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "tFCGO", + "name": "serverCardHeaderLeft", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "p3Ye7", + "name": "serverCardIcon", + "width": 36, + "height": 36, + "fill": "$--accent-muted", + "cornerRadius": "$--radius-md", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "fIPGI", + "width": 18, + "height": 18, + "iconFontName": "server", + "iconFontFamily": "lucide", + "fill": "$--accent" + } + ] + }, + { + "type": "frame", + "id": "V6GMoG", + "name": "serverCardNameCol", + "layout": "vertical", + "gap": 2, + "children": [ + { + "type": "text", + "id": "vWfaX", + "fill": "$--font-primary", + "content": "db-prod-1", + "fontFamily": "$--font-family", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "text", + "id": "B8hEF", + "fill": "$--font-tertiary", + "content": "192.168.1.101", + "fontFamily": "$--font-mono", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "id": "LrtKT", + "type": "ref", + "ref": "yXBxF", + "name": "serverCardBadge" + } + ] + }, + { + "type": "frame", + "id": "i13y9u", + "name": "serverCardMeta", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Q0cBP", + "fill": "$--font-secondary", + "content": "Ubuntu 24.04", + "fontFamily": "$--font-family", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "Lnj3c", + "fill": "$--font-tertiary", + "content": "·", + "fontFamily": "$--font-family", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "am8aJ", + "fill": "$--status-offline", + "content": "0% uptime", + "fontFamily": "$--font-family", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "text", + "id": "S95Qib", + "fill": "$--font-tertiary", + "content": "·", + "fontFamily": "$--font-family", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "IZNky", + "fill": "$--status-offline", + "content": "3h ago", + "fontFamily": "$--font-family", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "gNWAp", + "name": "serverCardBars", + "width": "fill_container", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "t3wZI9", + "name": "cpuBar", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "mkcgf", + "name": "cpuLabel2", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Tohlh", + "fill": "$--font-tertiary", + "content": "CPU", + "fontFamily": "$--font-family", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "p2YwF", + "fill": "$--font-primary", + "content": "0%", + "fontFamily": "$--font-family", + "fontSize": 11, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "J8EHl", + "name": "cpuTrack2", + "width": "fill_container", + "height": 4, + "fill": "$--bg-tertiary", + "cornerRadius": 2, + "children": [ + { + "type": "rectangle", + "cornerRadius": 2, + "id": "G0dp2", + "fill": "$--chart-blue", + "width": 0, + "height": 4 + } + ] + } + ] + }, + { + "type": "frame", + "id": "ZLaFx", + "name": "ramBar", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "N4q40v", + "name": "ramLabel2", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "eOwTv", + "fill": "$--font-tertiary", + "content": "RAM", + "fontFamily": "$--font-family", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "f9wW7A", + "fill": "$--font-primary", + "content": "12%", + "fontFamily": "$--font-family", + "fontSize": 11, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "Q8bxtp", + "name": "ramTrack2", + "width": "fill_container", + "height": 4, + "fill": "$--bg-tertiary", + "cornerRadius": 2, + "children": [ + { + "type": "rectangle", + "cornerRadius": 2, + "id": "huJzl", + "fill": "$--chart-orange", + "width": 19, + "height": 4 + } + ] + } + ] + }, + { + "type": "frame", + "id": "CRsuo", + "name": "diskBar", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "frame", + "id": "leUck", + "name": "diskLabel2", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "XJLj4", + "fill": "$--font-tertiary", + "content": "Disk", + "fontFamily": "$--font-family", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "KgQLt", + "fill": "$--font-primary", + "content": "67%", + "fontFamily": "$--font-family", + "fontSize": 11, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "y73x6x", + "name": "diskTrack2", + "width": "fill_container", + "height": 4, + "fill": "$--bg-tertiary", + "cornerRadius": 2, + "children": [ + { + "type": "rectangle", + "cornerRadius": 2, + "id": "bLVO8", + "fill": "$--chart-green", + "width": 104, + "height": 4 + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "vCVAY", + "name": "Project Group - Platform", + "width": "fill_container", + "layout": "vertical", + "gap": 12, + "children": [ + { + "id": "XmP9M", + "type": "ref", + "ref": "E9Fv2b", + "width": "fill_container", + "name": "platformHeader", + "descendants": { + "azbqM": { + "fill": "#8B5CF6" + }, + "c8QBh": { + "content": "Platform" + }, + "k9t2p": { + "content": "3 servers" + } + } + }, + { + "type": "frame", + "id": "nl43n", + "name": "Platform Cards Row", + "width": "fill_container", + "gap": 16, + "children": [ + { + "id": "p451ux", + "type": "ref", + "ref": "ocFcF", + "width": "fill_container", + "name": "card1", + "descendants": { + "mQd2O": { + "content": "api-prod-1" + }, + "JTnUG": { + "content": "10.0.1.20" + }, + "lwWfQ": { + "content": "Ubuntu 24.04" + }, + "EPbKp": { + "content": "99.9% uptime" + }, + "U99BRq": { + "content": "1m ago" + }, + "Ce6Px": { + "content": "38%" + }, + "P5enR": { + "width": 60 + }, + "y8UZx": { + "content": "72%" + }, + "gVnDg": { + "width": 112 + }, + "rz5Kp": { + "content": "45%" + }, + "QLV77": { + "width": 70 + } + } + }, + { + "id": "N5FR6f", + "type": "ref", + "ref": "ocFcF", + "width": "fill_container", + "name": "card2", + "descendants": { + "mQd2O": { + "content": "worker-1" + }, + "JTnUG": { + "content": "10.0.1.30" + }, + "lwWfQ": { + "content": "Ubuntu 22.04" + }, + "EPbKp": { + "content": "100% uptime" + }, + "U99BRq": { + "content": "just now" + }, + "Ce6Px": { + "content": "61%" + }, + "P5enR": { + "width": 95 + }, + "y8UZx": { + "content": "48%" + }, + "gVnDg": { + "width": 75 + }, + "rz5Kp": { + "content": "22%" + }, + "QLV77": { + "width": 34 + } + } + }, + { + "id": "fbVnt", + "type": "ref", + "ref": "ocFcF", + "width": "fill_container", + "name": "card3", + "descendants": { + "mQd2O": { + "content": "staging-1" + }, + "JTnUG": { + "content": "10.0.2.10" + }, + "lwWfQ": { + "content": "Ubuntu 24.04" + }, + "EPbKp": { + "content": "98.5% uptime", + "fill": "$--status-warning" + }, + "U99BRq": { + "content": "5m ago" + }, + "Ce6Px": { + "content": "12%" + }, + "P5enR": { + "width": 19 + }, + "y8UZx": { + "content": "34%" + }, + "gVnDg": { + "width": 53 + }, + "rz5Kp": { + "content": "56%" + }, + "QLV77": { + "width": 87 + } + } + } + ] + } + ] + }, + { + "type": "frame", + "id": "evYUR", + "name": "Ungrouped Servers", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "children": [ + { + "type": "text", + "id": "et2Eb", + "name": "ungroupedLabel", + "fill": "$--font-secondary", + "content": "Ungrouped", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "dkEE6", + "name": "Ungrouped Cards Row", + "width": "fill_container", + "gap": 16, + "children": [ + { + "id": "M5Gtm", + "type": "ref", + "ref": "ocFcF", + "width": "fill_container", + "name": "ucard1", + "descendants": { + "mQd2O": { + "content": "monitor-1" + }, + "JTnUG": { + "content": "10.0.3.5" + }, + "lwWfQ": { + "content": "Debian 12" + }, + "EPbKp": { + "content": "100% uptime" + }, + "U99BRq": { + "content": "just now" + }, + "Ce6Px": { + "content": "8%" + }, + "P5enR": { + "width": 12 + }, + "y8UZx": { + "content": "21%" + }, + "gVnDg": { + "width": 33 + }, + "rz5Kp": { + "content": "14%" + }, + "QLV77": { + "width": 22 + } + } + }, + { + "type": "frame", + "id": "whjZ3", + "name": "spacer", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "aQFLf", + "name": "spacer", + "width": "fill_container", + "height": 1 + } + ] + } + ] + } + ] + } + ] + } + ] } ], "themes": { diff --git a/lib/mast/fleet.ex b/lib/mast/fleet.ex index f2f9e04..0978f1b 100644 --- a/lib/mast/fleet.ex +++ b/lib/mast/fleet.ex @@ -145,6 +145,11 @@ defmodule Mast.Fleet do # --- Releases -------------------------------------------------------- + @doc "Returns the total number of Releases across the Fleet." + def count_all_releases do + Repo.aggregate(Release, :count, :id) + end + @doc "Lists Releases on a Server, ordered by effective handle." def list_releases(%Server{id: server_id}), do: list_releases(server_id) diff --git a/lib/mast/fleet/project.ex b/lib/mast/fleet/project.ex new file mode 100644 index 0000000..5f97304 --- /dev/null +++ b/lib/mast/fleet/project.ex @@ -0,0 +1,42 @@ +defmodule Mast.Fleet.Project do + @moduledoc """ + A named grouping of Servers. Purely organizational: a Project does not + own any behaviour, it only labels the Servers that belong together. + + See `CONTEXT.md` for the term, and issue #24 for scope. + """ + use Ecto.Schema + import Ecto.Changeset + + @colors ~w(slate indigo emerald amber rose violet) + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "projects" do + field :name, :string + field :description, :string + field :color, :string + + has_many :servers, Mast.Fleet.Server + + timestamps(type: :utc_datetime_usec) + end + + @doc "Returns the allowed color preset list." + def colors, do: @colors + + @doc false + def changeset(project, attrs) do + project + |> cast(attrs, [:name, :description, :color]) + |> update_change(:name, &trim_or_nil/1) + |> validate_required([:name]) + |> validate_length(:name, min: 1, max: 64) + |> validate_length(:description, max: 500) + |> validate_inclusion(:color, @colors, message: "must be one of: #{Enum.join(@colors, ", ")}") + |> unique_constraint(:name) + end + + defp trim_or_nil(nil), do: nil + defp trim_or_nil(s) when is_binary(s), do: String.trim(s) +end diff --git a/lib/mast/fleet/projects.ex b/lib/mast/fleet/projects.ex new file mode 100644 index 0000000..a3b069d --- /dev/null +++ b/lib/mast/fleet/projects.ex @@ -0,0 +1,220 @@ +defmodule Mast.Fleet.Projects do + @moduledoc """ + Context for the Project organizational lens over Servers. + + A Project groups Servers. Assignment is many-Servers-to-one-Project; + Servers without a Project still render in the Fleet, just not under a + group header. See CONTEXT.md and issue #24. + """ + import Ecto.Query, warn: false + + alias Ecto.Multi + alias Mast.Audit + alias Mast.Fleet.{Project, Server} + alias Mast.Repo + + # --- Reads --------------------------------------------------------------- + + @doc "Lists projects, ordered by name (case-insensitive via citext)." + def list_projects do + Project + |> order_by([p], asc: p.name) + |> Repo.all() + end + + @doc "Fetches a project by id. Raises if missing." + def get_project!(id), do: Repo.get!(Project, id) + + @doc "Returns `%{project_id => server_count}` for projects with members." + def server_counts do + Server + |> where([s], not is_nil(s.project_id)) + |> group_by([s], s.project_id) + |> select([s], {s.project_id, count(s.id)}) + |> Repo.all() + |> Map.new() + end + + @doc "Builds a changeset for forms." + def change_project(%Project{} = p, attrs \\ %{}), do: Project.changeset(p, attrs) + + # --- Writes -------------------------------------------------------------- + + @doc "Creates a project and writes a project.created audit event." + def create_project(attrs \\ %{}) do + Multi.new() + |> Multi.insert(:project, Project.changeset(%Project{}, attrs)) + |> Audit.multi_log(:audit, fn %{project: p} -> + %{ + event_type: "project.created", + subject_type: "Project", + subject_id: p.id, + metadata: %{"name" => p.name, "color" => p.color} + } + end) + |> Repo.transaction() + |> case do + {:ok, %{project: p}} -> {:ok, p} + {:error, :project, cs, _} -> {:error, cs} + end + end + + @doc """ + Updates a project. Emits at most one audit event per changed dimension + (`project.renamed` and/or `project.recolored`). A no-op update emits + nothing. + """ + def update_project(%Project{} = p, attrs) do + cs = Project.changeset(p, attrs) + + Multi.new() + |> Multi.update(:project, cs) + |> log_rename_if_changed(p, cs) + |> log_recolor_if_changed(p, cs) + |> Repo.transaction() + |> case do + {:ok, %{project: p}} -> {:ok, p} + {:error, :project, cs, _} -> {:error, cs} + end + end + + @doc """ + Deletes a project. Atomically: + + - removes the row + - emits `project.deleted` + - emits one `server.project_unassigned` per Server that referenced it + (the FK uses `on_delete: :nilify_all`, so the Server rows are + preserved with `project_id = nil`) + """ + def delete_project(%Project{} = p) do + affected = affected_server_ids(p.id) + + Multi.new() + |> Multi.delete(:project, p) + |> Audit.multi_log(:audit, %{ + event_type: "project.deleted", + subject_type: "Project", + subject_id: p.id, + metadata: %{"name" => p.name} + }) + |> log_cascade_unassigns(affected, p) + |> Repo.transaction() + |> case do + {:ok, %{project: p}} -> {:ok, p} + {:error, :project, cs, _} -> {:error, cs} + end + end + + @doc """ + Assigns a Server to a Project. No-op (no extra audit event) if the + Server already belongs to that Project. + """ + def assign_server(%Server{project_id: pid} = s, %Project{id: pid}), do: {:ok, s} + + def assign_server(%Server{} = s, %Project{} = p) do + cs = Server.changeset(s, %{project_id: p.id}) + + Multi.new() + |> Multi.update(:server, cs) + |> Audit.multi_log(:audit, fn %{server: s} -> + %{ + event_type: "server.project_assigned", + subject_type: "Server", + subject_id: s.id, + metadata: %{"project_id" => p.id, "project_name" => p.name} + } + end) + |> Repo.transaction() + |> case do + {:ok, %{server: s}} -> {:ok, s} + {:error, :server, cs, _} -> {:error, cs} + end + end + + @doc "Clears a Server's project assignment. No-op if already unassigned." + def unassign_server(%Server{project_id: nil} = s), do: {:ok, s} + + def unassign_server(%Server{} = s) do + prior_id = s.project_id + cs = Server.changeset(s, %{project_id: nil}) + + Multi.new() + |> Multi.update(:server, cs) + |> Audit.multi_log(:audit, fn %{server: s} -> + %{ + event_type: "server.project_unassigned", + subject_type: "Server", + subject_id: s.id, + metadata: %{"project_id" => prior_id} + } + end) + |> Repo.transaction() + |> case do + {:ok, %{server: s}} -> {:ok, s} + {:error, :server, cs, _} -> {:error, cs} + end + end + + # --- Helpers ------------------------------------------------------------- + + defp affected_server_ids(project_id) do + Server + |> where([s], s.project_id == ^project_id) + |> select([s], s.id) + |> Repo.all() + end + + defp log_rename_if_changed(multi, %Project{name: old}, cs) do + case Ecto.Changeset.get_change(cs, :name) do + nil -> + multi + + new when new == old -> + multi + + new -> + Audit.multi_log(multi, :audit_rename, fn %{project: p} -> + %{ + event_type: "project.renamed", + subject_type: "Project", + subject_id: p.id, + metadata: %{"from" => old, "to" => new} + } + end) + end + end + + defp log_recolor_if_changed(multi, %Project{color: old}, cs) do + case Ecto.Changeset.get_change(cs, :color) do + nil -> + multi + + new when new == old -> + multi + + new -> + Audit.multi_log(multi, :audit_recolor, fn %{project: p} -> + %{ + event_type: "project.recolored", + subject_type: "Project", + subject_id: p.id, + metadata: %{"from" => old, "to" => new} + } + end) + end + end + + defp log_cascade_unassigns(multi, [], _project), do: multi + + defp log_cascade_unassigns(multi, server_ids, %Project{} = p) do + Enum.reduce(server_ids, multi, fn server_id, acc -> + Audit.multi_log(acc, {:audit_unassign, server_id}, %{ + event_type: "server.project_unassigned", + subject_type: "Server", + subject_id: server_id, + metadata: %{"project_id" => p.id, "project_name" => p.name, "cause" => "project_deleted"} + }) + end) + end +end diff --git a/lib/mast/fleet/server.ex b/lib/mast/fleet/server.ex index 6f66e96..079eead 100644 --- a/lib/mast/fleet/server.ex +++ b/lib/mast/fleet/server.ex @@ -47,6 +47,7 @@ defmodule Mast.Fleet.Server do field :last_disk_counters, :map belongs_to :private_key, Mast.Keys.PrivateKey + belongs_to :project, Mast.Fleet.Project has_many :applications, Mast.Apps.Application has_many :releases, Mast.Fleet.Release @@ -68,13 +69,14 @@ defmodule Mast.Fleet.Server do @doc false def changeset(server, attrs) do server - |> cast(attrs, [:name, :host, :user, :port, :private_key_id]) + |> cast(attrs, [:name, :host, :user, :port, :private_key_id, :project_id]) |> validate_required([:name, :host]) |> validate_length(:name, min: 1, max: 64) |> validate_length(:host, min: 1, max: 255) |> validate_number(:port, greater_than: 0, less_than_or_equal_to: 65_535) |> unique_constraint(:name) |> foreign_key_constraint(:private_key_id) + |> foreign_key_constraint(:project_id) end @doc false diff --git a/lib/mast_web/components/ui.ex b/lib/mast_web/components/ui.ex index 248dd97..a3f2e6e 100644 --- a/lib/mast_web/components/ui.ex +++ b/lib/mast_web/components/ui.ex @@ -25,7 +25,7 @@ defmodule MastWeb.Components.UI do - `MastWeb.Components.UI.Navigation` — `ui_tabs`, `ui_sidebar`, `ui_page_header` - `MastWeb.Components.UI.Data` — `ui_stat`, `ui_metric`, `ui_metric_tile`, `ui_stat_tile`, `ui_kv_table`, `ui_card_title` - `MastWeb.Components.UI.Table` — `ui_table` - - `MastWeb.Components.UI.Domain` — `ui_server_card`, `ui_app_card`, `ui_app_row`, `ui_release_card`, `ui_log_entry`, `ui_audit_row` + - `MastWeb.Components.UI.Domain` — `ui_server_card`, `ui_app_card`, `ui_app_row`, `ui_release_card`, `ui_log_entry`, `ui_audit_row`, `ui_project_group_header`, `ui_project_badge` - `MastWeb.Components.UI.JS` — `show/2`, `hide/2` """ diff --git a/lib/mast_web/components/ui/domain.ex b/lib/mast_web/components/ui/domain.ex index 188fbef..235789d 100644 --- a/lib/mast_web/components/ui/domain.ex +++ b/lib/mast_web/components/ui/domain.ex @@ -22,24 +22,40 @@ defmodule MastWeb.Components.UI.Domain do navigate={"/servers/#{@server.id}"} class={[ "block bg-[var(--mast-bg-card)] border border-[var(--mast-border)]", - "rounded-[var(--radius-box)] p-4 shadow-sm transition-colors", + "rounded-[var(--radius-box)] p-4 sm:p-5 shadow-sm transition-colors", "hover:border-[var(--mast-accent)] hover:bg-[var(--mast-bg-card-hover)]", @class ]} > -
-
- <.ui_status_dot status={@server.status} /> - - {@server.name} +
+
+
<.ui_badge variant={server_badge_variant(@server.status)}> {server_status_label(@server.status)}
-
+
+ {@server.os_id || "—"} + + {card_last_seen(@server.last_seen_at)} +
+ +
<.ui_metric label="CPU" value={@server.cpu} /> <.ui_metric label="MEM" value={@server.memory} /> <.ui_metric label="DISK" value={@server.disk} /> @@ -48,6 +64,19 @@ defmodule MastWeb.Components.UI.Domain do """ end + defp card_last_seen(nil), do: "never seen" + + defp card_last_seen(%DateTime{} = t) do + diff = DateTime.diff(DateTime.utc_now(), t, :second) + + cond do + diff < 60 -> "just now" + diff < 3600 -> "#{div(diff, 60)}m ago" + diff < 86_400 -> "#{div(diff, 3600)}h ago" + true -> "#{div(diff, 86_400)}d ago" + end + end + defp server_badge_variant("up"), do: "online" defp server_badge_variant("down"), do: "offline" defp server_badge_variant(_), do: "neutral" @@ -356,4 +385,121 @@ defmodule MastWeb.Components.UI.Domain do defp audit_label("failure"), do: "failure" defp audit_label("key-create"), do: "key.create" defp audit_label(_), do: "action" + + @doc """ + Collapsible group header used to group servers by Project on the + Fleet page. Matches the `ProjectGroup/Header` pen component. + + <.ui_project_group_header + name="blog" + color="emerald" + count={2} + expanded?={true} + toggle={%{"phx-click" => "toggle-project", "phx-value-id" => p.id}} + /> + + `toggle` is a map of HTML attributes (typically `phx-click`/`phx-value-*`) + spread onto the clickable row. Pass an empty map for a static header. + """ + attr :name, :string, required: true + attr :color, :any, default: nil + attr :count, :integer, required: true + attr :expanded?, :boolean, default: true + attr :toggle, :map, default: %{} + + def ui_project_group_header(assigns) do + ~H""" +
+
+ """ + end + + @doc """ + Small Project chip — used as a badge near the Server detail header + and inside server cards when grouping is disabled. Color tints from + the preset palette. + + <.ui_project_badge name="blog" color="emerald" /> + """ + attr :name, :string, required: true + attr :color, :any, default: nil + attr :class, :any, default: nil + + def ui_project_badge(assigns) do + ~H""" + @name} + > + + """ + end + + @doc "Tailwind background class for a Project color preset." + def project_color_bg(nil), do: "bg-[var(--mast-font-tertiary)]" + def project_color_bg(""), do: "bg-[var(--mast-font-tertiary)]" + def project_color_bg("slate"), do: "bg-slate-500" + def project_color_bg("indigo"), do: "bg-indigo-500" + def project_color_bg("emerald"), do: "bg-emerald-500" + def project_color_bg("amber"), do: "bg-amber-500" + def project_color_bg("rose"), do: "bg-rose-500" + def project_color_bg("violet"), do: "bg-violet-500" + def project_color_bg(_), do: "bg-[var(--mast-font-tertiary)]" + + defp project_badge_classes(nil), + do: "bg-[var(--mast-bg-secondary)] text-[var(--mast-font-secondary)]" + + defp project_badge_classes(""), + do: "bg-[var(--mast-bg-secondary)] text-[var(--mast-font-secondary)]" + + defp project_badge_classes("slate"), do: "bg-slate-100 text-slate-700" + defp project_badge_classes("indigo"), do: "bg-indigo-100 text-indigo-700" + defp project_badge_classes("emerald"), do: "bg-emerald-100 text-emerald-700" + defp project_badge_classes("amber"), do: "bg-amber-100 text-amber-700" + defp project_badge_classes("rose"), do: "bg-rose-100 text-rose-700" + defp project_badge_classes("violet"), do: "bg-violet-100 text-violet-700" + + defp project_badge_classes(_), + do: "bg-[var(--mast-bg-secondary)] text-[var(--mast-font-secondary)]" end diff --git a/lib/mast_web/live/dashboard_live.ex b/lib/mast_web/live/dashboard_live.ex index a71fbee..1a1440c 100644 --- a/lib/mast_web/live/dashboard_live.ex +++ b/lib/mast_web/live/dashboard_live.ex @@ -2,7 +2,7 @@ defmodule MastWeb.DashboardLive do use MastWeb, :live_view alias Mast.Fleet - alias Mast.Fleet.Server + alias Mast.Fleet.{Project, Projects, Server} alias Mast.Keys alias Mast.Keys.PrivateKey alias Mast.Workers.ConnectionCheck @@ -19,9 +19,13 @@ defmodule MastWeb.DashboardLive do |> assign(:filter, "") |> assign(:servers, servers) |> assign(:keys, []) + |> assign(:projects, Projects.list_projects()) + |> assign(:collapsed_groups, MapSet.new()) |> assign(:form, nil) |> assign(:key_form, nil) - |> assign(:show_new_key, false)} + |> assign(:show_new_key, false) + |> assign(:project_form, nil) + |> assign(:show_new_project, false)} end @impl true @@ -53,6 +57,8 @@ defmodule MastWeb.DashboardLive do |> assign(:form, nil) |> assign(:show_new_key, false) |> assign(:key_form, nil) + |> assign(:show_new_project, false) + |> assign(:project_form, nil) end defp apply_action(socket, :new, _params) do @@ -61,8 +67,11 @@ defmodule MastWeb.DashboardLive do socket |> assign(:form, to_form(cs, as: :server)) |> assign(:keys, Keys.list_keys()) + |> assign(:projects, Projects.list_projects()) |> assign(:show_new_key, false) |> assign(:key_form, nil) + |> assign(:show_new_project, false) + |> assign(:project_form, nil) end @impl true @@ -99,6 +108,17 @@ defmodule MastWeb.DashboardLive do {:noreply, assign(socket, :filter, q)} end + def handle_event("toggle-project-group", %{"id" => id}, socket) do + collapsed = + if MapSet.member?(socket.assigns.collapsed_groups, id) do + MapSet.delete(socket.assigns.collapsed_groups, id) + else + MapSet.put(socket.assigns.collapsed_groups, id) + end + + {:noreply, assign(socket, :collapsed_groups, collapsed)} + end + def handle_event("cancel", _, socket) do {:noreply, push_patch(socket, to: ~p"/")} end @@ -135,7 +155,7 @@ defmodule MastWeb.DashboardLive do form = socket.assigns.form - |> server_form_with_key(key.id) + |> server_form_with(:private_key_id, key.id) {:noreply, socket @@ -149,17 +169,66 @@ defmodule MastWeb.DashboardLive do end end + def handle_event("toggle_new_project", _, socket) do + show? = not socket.assigns.show_new_project + + project_form = + if show? do + to_form(Projects.change_project(%Project{}), as: :project) + else + nil + end + + {:noreply, + socket + |> assign(:show_new_project, show?) + |> assign(:project_form, project_form)} + end + + def handle_event("validate_project", %{"project" => params}, socket) do + cs = + %Project{} + |> Projects.change_project(params) + |> Map.put(:action, :validate) + + {:noreply, assign(socket, :project_form, to_form(cs, as: :project))} + end + + def handle_event("save_project", %{"project" => params}, socket) do + case Projects.create_project(params) do + {:ok, project} -> + projects = Projects.list_projects() + + form = + socket.assigns.form + |> server_form_with(:project_id, project.id) + + {:noreply, + socket + |> assign(:projects, projects) + |> assign(:form, form) + |> assign(:show_new_project, false) + |> assign(:project_form, nil)} + + {:error, cs} -> + {:noreply, assign(socket, :project_form, to_form(cs, as: :project))} + end + end + # --- Render --------------------------------------------------------------- @impl true def render(assigns) do visible = filtered(assigns.servers, assigns.filter) stats = fleet_stats(assigns.servers) + {grouped, ungrouped} = group_by_project(visible, assigns.projects) assigns = assigns |> assign(:visible_servers, visible) |> assign(:stats, stats) + |> assign(:grouped, grouped) + |> assign(:ungrouped, ungrouped) ~H""" @@ -183,7 +252,7 @@ defmodule MastWeb.DashboardLive do value={@stats.updates} tone={if @stats.updates > 0, do: "warning", else: "default"} /> - <.ui_stat label="Seen Recently" value={@stats.seen_recently} /> + <.ui_stat label="Releases" value={@stats.releases} />
@@ -208,11 +277,40 @@ defmodule MastWeb.DashboardLive do No servers match "{@filter}".
-
- <.ui_server_card :for={s <- @visible_servers} server={s} /> +
+
+ <.ui_project_group_header + name={project.name} + color={project.color} + count={length(members)} + expanded?={not MapSet.member?(@collapsed_groups, project.id)} + toggle={%{"phx-click" => "toggle-project-group", "phx-value-id" => project.id}} + /> +
+ <.ui_server_card :for={s <- members} server={s} /> +
+
+ +
+
+ + Ungrouped + + + + {length(@ungrouped)} {if length(@ungrouped) == 1, do: "server", else: "servers"} + +
+
+ <.ui_server_card :for={s <- @ungrouped} server={s} /> +
+
@@ -252,6 +350,22 @@ defmodule MastWeb.DashboardLive do > {if @show_new_key, do: "Cancel adding new key", else: "Add new key"} + + <.input + field={@form[:project_id]} + type="select" + label="Project" + prompt={if @projects == [], do: "No projects yet", else: "— none —"} + options={Enum.map(@projects, &{&1.name, &1.id})} + /> + +
@@ -281,6 +395,31 @@ defmodule MastWeb.DashboardLive do
+
+ <.form + for={@project_form} + id="new-project-form" + phx-change="validate_project" + phx-submit="save_project" + class="space-y-2" + > + <.input field={@project_form[:name]} label="Project name" placeholder="blog" required /> + <.input + field={@project_form[:description]} + label="Description (optional)" + placeholder="Customer-facing blog stack" + /> + <.input + field={@project_form[:color]} + type="select" + label="Color (optional)" + prompt="— none —" + options={Enum.map(Project.colors(), &{&1, &1})} + /> + <.ui_button type="submit" form="new-project-form">Save project + +
+ <:footer> <.ui_button variant="secondary" phx-click={Phoenix.LiveView.JS.patch(~p"/")}> Cancel @@ -305,21 +444,27 @@ defmodule MastWeb.DashboardLive do end) end + defp group_by_project(servers, projects) do + by_project = Enum.group_by(servers, & &1.project_id) + + grouped = + projects + |> Enum.map(fn p -> {p, Map.get(by_project, p.id, [])} end) + |> Enum.reject(fn {_p, members} -> members == [] end) + + ungrouped = Map.get(by_project, nil, []) + {grouped, ungrouped} + end + defp fleet_stats(servers) do %{ total: length(servers), online: Enum.count(servers, &(&1.status == "up")), updates: Enum.reduce(servers, 0, fn s, acc -> acc + (s.updates_available || 0) end), - seen_recently: Enum.count(servers, &seen_recently?/1) + releases: Fleet.count_all_releases() } end - defp seen_recently?(%{last_seen_at: nil}), do: false - - defp seen_recently?(%{last_seen_at: t}) do - DateTime.diff(DateTime.utc_now(), t, :second) < 600 - end - defp fleet_subtitle(%{total: 0}), do: "Register your first server below" defp fleet_subtitle(%{total: n, online: online}) do @@ -334,11 +479,11 @@ defmodule MastWeb.DashboardLive do |> Enum.map(fn {_field, {msg, _opts}} -> msg end) end - defp server_form_with_key(form, key_id) do + defp server_form_with(form, field, value) do params = form.params |> Map.new() - |> Map.put("private_key_id", to_string(key_id)) + |> Map.put(to_string(field), to_string(value)) %Server{} |> Fleet.change_server(params) diff --git a/lib/mast_web/live/dev_ui_live/view.ex b/lib/mast_web/live/dev_ui_live/view.ex index 6f7009e..42ed096 100644 --- a/lib/mast_web/live/dev_ui_live/view.ex +++ b/lib/mast_web/live/dev_ui_live/view.ex @@ -320,10 +320,13 @@ defmodule MastWeb.DevUiLive.View do server = %{ id: "00000000-0000-0000-0000-000000000000", name: "web-prod-1", + host: "192.168.1.100", + os_id: "Ubuntu 24.04", status: "up", cpu: 42, memory: 78, - disk: 60 + disk: 60, + last_seen_at: DateTime.add(DateTime.utc_now(), -120, :second) } assigns = assign(assigns, :server, server) @@ -407,6 +410,26 @@ defmodule MastWeb.DevUiLive.View do <.ui_log_entry time="14:22:06" kind={:error}>Job failed
+ + <.ui_card padded={false}> + <:title>Project group header +
+ <.ui_project_group_header name="blog" color="emerald" count={2} expanded?={true} /> + <.ui_project_group_header name="platform" color="indigo" count={3} expanded?={true} /> + <.ui_project_group_header name="internal-tools" color={nil} count={1} expanded?={false} /> +
+ + + <.ui_card padded={false}> + <:title>Project badge +
+ <.ui_project_badge name="blog" color="emerald" /> + <.ui_project_badge name="platform" color="indigo" /> + <.ui_project_badge name="hot-fixes" color="rose" /> + <.ui_project_badge name="experiments" color="violet" /> + <.ui_project_badge name="archive" color={nil} /> +
+ """ end end diff --git a/lib/mast_web/live/server_live.ex b/lib/mast_web/live/server_live.ex index 10638c7..1d30155 100644 --- a/lib/mast_web/live/server_live.ex +++ b/lib/mast_web/live/server_live.ex @@ -6,7 +6,7 @@ defmodule MastWeb.ServerLive do use MastWeb, :live_view alias Mast.{Apps, Fleet} - alias Mast.Fleet.Release + alias Mast.Fleet.{Projects, Release} alias Mast.Workers.{ApplyUpdates, AppProbe, ConnectionCheck, PatchScan} alias MastWeb.ServerLive.{ @@ -48,6 +48,9 @@ defmodule MastWeb.ServerLive do |> assign(:show_system_apps?, false) |> assign(:confirm_delete?, false) |> assign(:confirm_name, "") + |> assign(:projects, Projects.list_projects()) + |> assign(:project, load_project(server)) + |> assign(:project_form, project_form(server)) |> assign(:updates_page, 1) |> assign(:updates_page_size, 10) |> assign(:updates_filter, "") @@ -348,6 +351,43 @@ defmodule MastWeb.ServerLive do |> assign(:running?, false)} end + def handle_event("update-project", %{"server" => params}, socket) do + server = socket.assigns.server + new_id = blank_to_nil(params["project_id"]) + + result = + case {server.project_id, new_id} do + {same, same} -> {:ok, server} + {_, nil} -> Projects.unassign_server(server) + {_, id} -> Projects.assign_server(server, Projects.get_project!(id)) + end + + case result do + {:ok, updated} -> + {:noreply, + socket + |> assign(:server, updated) + |> assign(:project, load_project(updated)) + |> assign(:project_form, project_form(updated)) + |> assign(:activity, load_activity(updated.id)) + |> put_flash(:info, "Project saved.")} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Could not update project.")} + end + end + + defp blank_to_nil(""), do: nil + defp blank_to_nil(nil), do: nil + defp blank_to_nil(v), do: v + + defp project_form(server) do + to_form(Fleet.change_server(server, %{}), as: :server) + end + + defp load_project(%{project_id: nil}), do: nil + defp load_project(%{project_id: id}), do: Projects.get_project!(id) + defp enqueue_apply(socket, extra) do args = Map.merge(extra, %{ @@ -411,6 +451,7 @@ defmodule MastWeb.ServerLive do running?={@running?} probing?={@probing?} scan_error={@scan_error} + project={@project} /> <%= case @tab do %> @@ -447,6 +488,8 @@ defmodule MastWeb.ServerLive do server={@server} confirm_delete?={@confirm_delete?} confirm_name={@confirm_name} + projects={@projects} + project_form={@project_form} /> <% end %> diff --git a/lib/mast_web/live/server_live/header.ex b/lib/mast_web/live/server_live/header.ex index 9ab4f22..4ccbe1f 100644 --- a/lib/mast_web/live/server_live/header.ex +++ b/lib/mast_web/live/server_live/header.ex @@ -13,6 +13,7 @@ defmodule MastWeb.ServerLive.Header do attr :running?, :boolean, required: true attr :probing?, :boolean, required: true attr :scan_error, :any, default: nil + attr :project, :any, default: nil def detail_header(assigns) do ~H""" @@ -54,6 +55,7 @@ defmodule MastWeb.ServerLive.Header do

{@server.name}

+ <.ui_project_badge :if={@project} name={@project.name} color={@project.color} />

{@server.host} · {@server.os_id || "—"} · {last_seen(@server)}

diff --git a/lib/mast_web/live/server_live/settings_tab.ex b/lib/mast_web/live/server_live/settings_tab.ex index 03c1b66..9adb64b 100644 --- a/lib/mast_web/live/server_live/settings_tab.ex +++ b/lib/mast_web/live/server_live/settings_tab.ex @@ -9,6 +9,8 @@ defmodule MastWeb.ServerLive.SettingsTab do attr :server, :map, required: true attr :confirm_delete?, :boolean, required: true attr :confirm_name, :string, required: true + attr :projects, :list, required: true + attr :project_form, :any, required: true def render(assigns) do ~H""" @@ -21,6 +23,35 @@ defmodule MastWeb.ServerLive.SettingsTab do <:row label="Package manager">{@server.package_manager || "—"} +
+
+
+

Project

+

+ Group this server with others under a Project. Reassignment is recorded as an audit event. +

+
+
+ + <.form + for={@project_form} + id="server-project-form" + phx-submit="update-project" + class="space-y-4" + > + <.input + field={@project_form[:project_id]} + type="select" + label="Project" + prompt="— none —" + options={Enum.map(@projects, &{&1.name, &1.id})} + /> +
+ <.ui_button type="submit" form="server-project-form">Save +
+ +
+
diff --git a/lib/mast_web/live/settings_live.ex b/lib/mast_web/live/settings_live.ex index 0d87aa7..27c8934 100644 --- a/lib/mast_web/live/settings_live.ex +++ b/lib/mast_web/live/settings_live.ex @@ -11,10 +11,14 @@ defmodule MastWeb.SettingsLive do """ use MastWeb, :live_view + import MastWeb.SettingsLive.ProjectsTab, only: [projects_tab: 1] + + alias Mast.Fleet.Project + alias Mast.Fleet.Projects alias Mast.Keys alias Mast.Keys.PrivateKey - @tabs ~w(general keys) + @tabs ~w(general keys projects) @impl true def mount(_params, _session, socket) do @@ -24,7 +28,12 @@ defmodule MastWeb.SettingsLive do |> assign(:tab, "general") |> assign(:show_new_key, false) |> assign(:key_form, nil) - |> load_keys()} + |> assign(:show_new_project, false) + |> assign(:project_form, nil) + |> assign(:editing_project_id, nil) + |> assign(:edit_project_form, nil) + |> load_keys() + |> load_projects()} end defp load_keys(socket) do @@ -33,6 +42,12 @@ defmodule MastWeb.SettingsLive do |> assign(:server_counts, Keys.server_counts()) end + defp load_projects(socket) do + socket + |> assign(:projects, Projects.list_projects()) + |> assign(:project_server_counts, Projects.server_counts()) + end + @impl true def handle_params(%{"tab" => tab}, _url, socket) when tab in @tabs do {:noreply, assign(socket, :tab, tab)} @@ -98,6 +113,107 @@ defmodule MastWeb.SettingsLive do end end + # --- Project events ------------------------------------------------------ + + def handle_event("toggle-new-project", _, socket) do + show? = not socket.assigns.show_new_project + + project_form = + if show? do + to_form(Projects.change_project(%Project{}), as: :project) + else + nil + end + + {:noreply, + socket + |> assign(:show_new_project, show?) + |> assign(:project_form, project_form)} + end + + def handle_event("validate-project", %{"project" => params}, socket) do + cs = + %Project{} + |> Projects.change_project(params) + |> Map.put(:action, :validate) + + {:noreply, assign(socket, :project_form, to_form(cs, as: :project))} + end + + def handle_event("save-project", %{"project" => params}, socket) do + case Projects.create_project(params) do + {:ok, _p} -> + {:noreply, + socket + |> assign(:show_new_project, false) + |> assign(:project_form, nil) + |> put_flash(:info, "Project added.") + |> load_projects()} + + {:error, cs} -> + {:noreply, assign(socket, :project_form, to_form(cs, as: :project))} + end + end + + def handle_event("edit-project", %{"id" => id}, socket) do + project = Projects.get_project!(id) + + {:noreply, + socket + |> assign(:editing_project_id, project.id) + |> assign(:edit_project_form, to_form(Projects.change_project(project), as: :project))} + end + + def handle_event("cancel-edit-project", _, socket) do + {:noreply, + socket + |> assign(:editing_project_id, nil) + |> assign(:edit_project_form, nil)} + end + + def handle_event("validate-edit-project", %{"project" => params}, socket) do + project = Projects.get_project!(socket.assigns.editing_project_id) + + cs = + project + |> Projects.change_project(params) + |> Map.put(:action, :validate) + + {:noreply, assign(socket, :edit_project_form, to_form(cs, as: :project))} + end + + def handle_event("update-project", %{"project" => params}, socket) do + project = Projects.get_project!(socket.assigns.editing_project_id) + + case Projects.update_project(project, params) do + {:ok, _p} -> + {:noreply, + socket + |> assign(:editing_project_id, nil) + |> assign(:edit_project_form, nil) + |> put_flash(:info, "Project updated.") + |> load_projects()} + + {:error, cs} -> + {:noreply, assign(socket, :edit_project_form, to_form(cs, as: :project))} + end + end + + def handle_event("delete-project", %{"id" => id}, socket) do + project = Projects.get_project!(id) + + case Projects.delete_project(project) do + {:ok, _} -> + {:noreply, + socket + |> put_flash(:info, "Project #{project.name} removed.") + |> load_projects()} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Could not remove project.")} + end + end + # --- Render --------------------------------------------------------------- @impl true @@ -120,6 +236,7 @@ defmodule MastWeb.SettingsLive do <.ui_tabs active={@tab} class="mt-4 border-b-0"> <:tab key="general" patch={~p"/settings?tab=general"}>General <:tab key="keys" patch={~p"/settings?tab=keys"}>SSH Keys + <:tab key="projects" patch={~p"/settings?tab=projects"}>Projects @@ -131,6 +248,15 @@ defmodule MastWeb.SettingsLive do show_new_key={@show_new_key} key_form={@key_form} /> + <% "projects" -> %> + <.projects_tab + projects={@projects} + server_counts={@project_server_counts} + show_new_project={@show_new_project} + project_form={@project_form} + editing_project_id={@editing_project_id} + edit_project_form={@edit_project_form} + /> <% _ -> %> <.ui_card padded={false}> <.ui_empty diff --git a/lib/mast_web/live/settings_live/projects_tab.ex b/lib/mast_web/live/settings_live/projects_tab.ex new file mode 100644 index 0000000..b55c115 --- /dev/null +++ b/lib/mast_web/live/settings_live/projects_tab.ex @@ -0,0 +1,240 @@ +defmodule MastWeb.SettingsLive.ProjectsTab do + @moduledoc """ + Projects tab on the Settings page. List, inline-create, edit (rename + + recolor), and delete Projects. Mirrors the SSH Keys tab pattern. + + Pure render module; event handlers live on `MastWeb.SettingsLive`. + """ + use MastWeb, :html + + alias Mast.Fleet.Project + + attr :projects, :list, required: true + attr :server_counts, :map, required: true + attr :show_new_project, :boolean, required: true + attr :project_form, :any, required: true + attr :editing_project_id, :any, required: true + attr :edit_project_form, :any, required: true + + def projects_tab(assigns) do + ~H""" +
+
+

Projects

+ + {length(@projects)} {if length(@projects) == 1, do: "project", else: "projects"} + + + <.ui_button icon="hero-plus" size="sm" phx-click="toggle-new-project"> + Add Project + +
+ + <.ui_modal + :if={@show_new_project} + id="new-project-modal" + on_cancel={JS.push("toggle-new-project")} + > + <:title>Add a project + <:subtitle>Group servers under a shared label. + + <.form + for={@project_form} + id="new-project-form" + phx-change="validate-project" + phx-submit="save-project" + class="space-y-3" + > + <.input + field={@project_form[:name]} + label="Project name" + placeholder="blog" + required + /> + <.input + field={@project_form[:description]} + label="Description (optional)" + placeholder="Customer-facing blog stack" + /> + <.color_picker field={@project_form[:color]} /> + + + <:footer> + <.ui_button type="button" variant="secondary" phx-click="toggle-new-project"> + Cancel + + <.ui_button type="submit" form="new-project-form">Save project + + + + <%= if @projects == [] do %> +
+ <.ui_empty + icon="hero-folder" + title="No projects yet" + body="Group related servers into a Project to organize the Fleet view." + /> +
+ <% else %> +
    +
  • + <%= if @editing_project_id == p.id do %> + <.form + for={@edit_project_form} + id={"edit-project-form-#{p.id}"} + phx-change="validate-edit-project" + phx-submit="update-project" + class="space-y-3" + > + <.input field={@edit_project_form[:name]} label="Name" required /> + <.input field={@edit_project_form[:description]} label="Description" /> + <.color_picker field={@edit_project_form[:color]} /> +
    + <.ui_button + type="button" + variant="secondary" + size="sm" + phx-click="cancel-edit-project" + > + Cancel + + <.ui_button type="submit" size="sm" form={"edit-project-form-#{p.id}"}> + Save + +
    + + <% else %> +
    +
    + <% end %> +
  • +
+ <% end %> + +
+ +

+ Projects are an organizational lens over the Fleet. Deleting a Project leaves its Servers in place; they go back to being ungrouped. +

+
+
+ """ + end + + attr :field, Phoenix.HTML.FormField, required: true + + defp color_picker(assigns) do + ~H""" +
+ +
+ + + +
+

+ {msg} +

+
+ """ + end + + defp field_errors(%Phoenix.HTML.FormField{errors: errors}) do + Enum.map(errors, fn {msg, _opts} -> msg end) + end + + @doc "Tailwind background class for a Project color preset." + def color_swatch(nil), do: "bg-[var(--mast-bg-secondary)]" + def color_swatch(""), do: "bg-[var(--mast-bg-secondary)]" + def color_swatch("slate"), do: "bg-slate-500" + def color_swatch("indigo"), do: "bg-indigo-500" + def color_swatch("emerald"), do: "bg-emerald-500" + def color_swatch("amber"), do: "bg-amber-500" + def color_swatch("rose"), do: "bg-rose-500" + def color_swatch("violet"), do: "bg-violet-500" + def color_swatch(_), do: "bg-[var(--mast-bg-secondary)]" + + defp server_count_label(counts, project_id) do + n = Map.get(counts, project_id, 0) + "#{n} #{if n == 1, do: "server", else: "servers"}" + end +end diff --git a/priv/repo/migrations/20260523192231_create_projects.exs b/priv/repo/migrations/20260523192231_create_projects.exs new file mode 100644 index 0000000..5453081 --- /dev/null +++ b/priv/repo/migrations/20260523192231_create_projects.exs @@ -0,0 +1,24 @@ +defmodule Mast.Repo.Migrations.CreateProjects do + use Ecto.Migration + + def change do + execute "CREATE EXTENSION IF NOT EXISTS citext", "DROP EXTENSION IF EXISTS citext" + + create table(:projects, primary_key: false) do + add :id, :binary_id, primary_key: true + add :name, :citext, null: false + add :description, :text + add :color, :string + + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:projects, [:name]) + + alter table(:servers) do + add :project_id, references(:projects, type: :binary_id, on_delete: :nilify_all) + end + + create index(:servers, [:project_id]) + end +end diff --git a/test/mast/fleet/projects_test.exs b/test/mast/fleet/projects_test.exs new file mode 100644 index 0000000..73a9f90 --- /dev/null +++ b/test/mast/fleet/projects_test.exs @@ -0,0 +1,202 @@ +defmodule Mast.Fleet.ProjectsTest do + use Mast.DataCase, async: true + + alias Mast.Audit + alias Mast.Fleet + alias Mast.Fleet.Projects + + describe "create_project/1" do + test "creates a project with name only" do + assert {:ok, p} = Projects.create_project(%{name: "blog"}) + assert p.name == "blog" + assert p.description in [nil, ""] + assert p.color == nil + end + + test "accepts a description and color from the preset palette" do + assert {:ok, p} = + Projects.create_project(%{ + name: "infra", + description: "shared boxes", + color: "indigo" + }) + + assert p.description == "shared boxes" + assert p.color == "indigo" + end + + test "rejects a blank name" do + assert {:error, cs} = Projects.create_project(%{name: ""}) + assert "can't be blank" in errors_on(cs).name + end + + test "rejects a color outside the preset palette" do + assert {:error, cs} = Projects.create_project(%{name: "x", color: "#bada55"}) + assert errors_on(cs)[:color] != nil + end + + test "rejects a duplicate name (case-insensitive)" do + assert {:ok, _} = Projects.create_project(%{name: "Blog"}) + assert {:error, cs} = Projects.create_project(%{name: "blog"}) + assert "has already been taken" in errors_on(cs).name + end + + test "emits a project.created audit event" do + {:ok, p} = Projects.create_project(%{name: "audit-create", color: "emerald"}) + + assert [event] = Audit.list_for_subject("Project", p.id) + assert event.event_type == "project.created" + assert event.metadata["name"] == "audit-create" + assert event.metadata["color"] == "emerald" + end + end + + describe "update_project/2" do + test "renames a project and emits project.renamed" do + {:ok, p} = Projects.create_project(%{name: "old-name"}) + + assert {:ok, p2} = Projects.update_project(p, %{name: "new-name"}) + assert p2.name == "new-name" + + types = + "Project" + |> Audit.list_for_subject(p.id) + |> Enum.map(& &1.event_type) + + assert "project.renamed" in types + end + + test "recoloring emits project.recolored" do + {:ok, p} = Projects.create_project(%{name: "rec", color: "slate"}) + + assert {:ok, p2} = Projects.update_project(p, %{color: "rose"}) + assert p2.color == "rose" + + types = + "Project" + |> Audit.list_for_subject(p.id) + |> Enum.map(& &1.event_type) + + assert "project.recolored" in types + end + + test "an update that changes nothing emits no audit event beyond creation" do + {:ok, p} = Projects.create_project(%{name: "noop"}) + {:ok, _} = Projects.update_project(p, %{name: "noop"}) + + types = + "Project" + |> Audit.list_for_subject(p.id) + |> Enum.map(& &1.event_type) + + assert types == ["project.created"] + end + end + + describe "delete_project/1" do + test "removes the project and emits project.deleted" do + {:ok, p} = Projects.create_project(%{name: "to-delete"}) + + assert {:ok, _} = Projects.delete_project(p) + assert Projects.list_projects() == [] + + types = + "Project" + |> Audit.list_for_subject(p.id) + |> Enum.map(& &1.event_type) + + assert "project.deleted" in types + end + + test "cascade-unassigns servers, emitting one server.project_unassigned each" do + {:ok, p} = Projects.create_project(%{name: "cascade"}) + + {:ok, s1} = Fleet.create_server(%{name: "alpha", host: "10.0.0.1", project_id: p.id}) + {:ok, s2} = Fleet.create_server(%{name: "bravo", host: "10.0.0.2", project_id: p.id}) + + assert {:ok, _} = Projects.delete_project(p) + + assert Fleet.get_server!(s1.id).project_id == nil + assert Fleet.get_server!(s2.id).project_id == nil + + for s <- [s1, s2] do + types = + "Server" + |> Audit.list_for_subject(s.id) + |> Enum.map(& &1.event_type) + + assert "server.project_unassigned" in types + end + end + end + + describe "assign_server/2 + unassign_server/1" do + test "assigning sets project_id and emits server.project_assigned" do + {:ok, p} = Projects.create_project(%{name: "assign"}) + {:ok, s} = Fleet.create_server(%{name: "needs-project", host: "10.0.0.5"}) + + assert {:ok, s2} = Projects.assign_server(s, p) + assert s2.project_id == p.id + + types = + "Server" + |> Audit.list_for_subject(s.id) + |> Enum.map(& &1.event_type) + + assert "server.project_assigned" in types + end + + test "re-assigning to the same project is a no-op (no extra audit event)" do + {:ok, p} = Projects.create_project(%{name: "same"}) + {:ok, s} = Fleet.create_server(%{name: "stay", host: "10.0.0.6", project_id: p.id}) + + assert {:ok, _} = Projects.assign_server(s, p) + + counts = + "Server" + |> Audit.list_for_subject(s.id) + |> Enum.frequencies_by(& &1.event_type) + + assert Map.get(counts, "server.project_assigned", 0) <= 1 + end + + test "unassign clears project_id and emits server.project_unassigned" do + {:ok, p} = Projects.create_project(%{name: "unassign"}) + {:ok, s} = Fleet.create_server(%{name: "leaving", host: "10.0.0.7", project_id: p.id}) + + assert {:ok, s2} = Projects.unassign_server(s) + assert s2.project_id == nil + + types = + "Server" + |> Audit.list_for_subject(s.id) + |> Enum.map(& &1.event_type) + + assert "server.project_unassigned" in types + end + end + + describe "list_projects/0 and server_counts/0" do + test "list_projects/0 returns rows ordered by name (case-insensitive)" do + {:ok, _} = Projects.create_project(%{name: "Zeta"}) + {:ok, _} = Projects.create_project(%{name: "alpha"}) + {:ok, _} = Projects.create_project(%{name: "Mike"}) + + assert ["alpha", "Mike", "Zeta"] = Enum.map(Projects.list_projects(), & &1.name) + end + + test "server_counts/0 returns a map keyed by project_id with server counts" do + {:ok, p1} = Projects.create_project(%{name: "p1"}) + {:ok, p2} = Projects.create_project(%{name: "p2"}) + + {:ok, _} = Fleet.create_server(%{name: "s1", host: "10.1.0.1", project_id: p1.id}) + {:ok, _} = Fleet.create_server(%{name: "s2", host: "10.1.0.2", project_id: p1.id}) + {:ok, _} = Fleet.create_server(%{name: "s3", host: "10.1.0.3", project_id: p2.id}) + {:ok, _} = Fleet.create_server(%{name: "lone", host: "10.1.0.4"}) + + counts = Projects.server_counts() + assert counts[p1.id] == 2 + assert counts[p2.id] == 1 + end + end +end diff --git a/test/mast_web/live/dashboard_live_test.exs b/test/mast_web/live/dashboard_live_test.exs index 8fc3bdb..57b75f5 100644 --- a/test/mast_web/live/dashboard_live_test.exs +++ b/test/mast_web/live/dashboard_live_test.exs @@ -194,4 +194,108 @@ defmodule MastWeb.DashboardLiveTest do assert render(view) =~ "Fleet Overview" end end + + describe "fleet grouping by project" do + alias Mast.Fleet.Projects + + test "renders a collapsible group header for each project with members", %{conn: conn} do + {:ok, blog} = Projects.create_project(%{name: "blog", color: "emerald"}) + + {:ok, _} = + Fleet.create_server(%{name: "blog-prod-1", host: "10.0.1.1", project_id: blog.id}) + + {:ok, _} = + Fleet.create_server(%{name: "blog-prod-2", host: "10.0.1.2", project_id: blog.id}) + + {:ok, _} = Fleet.create_server(%{name: "lone", host: "10.0.1.3"}) + + {:ok, _view, html} = live(conn, ~p"/") + + assert html =~ "blog" + # Group count rendered inside the header. + assert html =~ "2 servers" + # The ungrouped server still shows. + assert html =~ "lone" + # No "Unassigned" pseudo-group. + refute html =~ "Unassigned" + end + + test "clicking a group header toggles its expanded state", %{conn: conn} do + {:ok, p} = Projects.create_project(%{name: "infra"}) + {:ok, _} = Fleet.create_server(%{name: "infra-1", host: "10.0.2.1", project_id: p.id}) + + {:ok, view, html} = live(conn, ~p"/") + assert html =~ "infra-1" + + view + |> element("[phx-click='toggle-project-group'][phx-value-id='#{p.id}']") + |> render_click() + + html = render(view) + # Card is gone from DOM when collapsed. + refute html =~ "infra-1" + end + end + + describe "Add Server modal — projects" do + alias Mast.Fleet.Projects + + test "shows a Project dropdown listing registered projects", %{conn: conn} do + {:ok, _p} = Projects.create_project(%{name: "blog"}) + + {:ok, view, _} = live(conn, ~p"/servers/new") + html = render(view) + + assert html =~ "Project" + assert html =~ "blog" + end + + test "submits a new server with project_id", %{conn: conn} do + {:ok, p} = Projects.create_project(%{name: "platform"}) + + {:ok, view, _} = live(conn, ~p"/servers/new") + + view + |> form("#new-server-form", + server: %{name: "with-project", host: "10.0.0.30", project_id: p.id} + ) + |> render_submit() + + [server] = Mast.Fleet.list_servers() + assert server.name == "with-project" + assert server.project_id == p.id + end + + test "inline 'Add new project' expands a form inside the modal", %{conn: conn} do + {:ok, view, _} = live(conn, ~p"/servers/new") + + refute has_element?(view, "#new-project-form") + + view |> element("[phx-click=toggle_new_project]") |> render_click() + + assert has_element?(view, "#new-project-form") + end + + test "inline project creation selects the new project on the server form", %{conn: conn} do + {:ok, view, _} = live(conn, ~p"/servers/new") + + view |> element("[phx-click=toggle_new_project]") |> render_click() + + view + |> form("#new-project-form", project: %{name: "infra", color: "indigo"}) + |> render_submit() + + [project] = Projects.list_projects() + html = render(view) + + assert html =~ "infra" + # The new project is the selected option on the server form. + selected_option = + Regex.run(~r/]*selected[^>]*value="([^"]+)"[^>]*>infra/, html) || + Regex.run(~r/]*value="([^"]+)"[^>]*selected[^>]*>infra/, html) + + assert selected_option, "Expected the 'infra' option to be selected. Got: #{html}" + assert Enum.at(selected_option, 1) == project.id + end + end end diff --git a/test/mast_web/live/dev_ui_live_test.exs b/test/mast_web/live/dev_ui_live_test.exs index 33c8491..d7bdbe2 100644 --- a/test/mast_web/live/dev_ui_live_test.exs +++ b/test/mast_web/live/dev_ui_live_test.exs @@ -36,5 +36,14 @@ defmodule MastWeb.DevUiLiveTest do assert html_375 =~ "max-w-[375px]" end + + test "renders previews for the new Project components", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/dev/ui") + + # ProjectGroup/Header (pen) preview. + assert html =~ "Project group header" + # Project badge preview. + assert html =~ "Project badge" + end end end diff --git a/test/mast_web/live/server_live_test.exs b/test/mast_web/live/server_live_test.exs index d4c6d8c..cb3392b 100644 --- a/test/mast_web/live/server_live_test.exs +++ b/test/mast_web/live/server_live_test.exs @@ -314,4 +314,78 @@ defmodule MastWeb.ServerLiveTest do refute html =~ "ghost" end end + + describe "header — project badge" do + alias Mast.Fleet.Projects + + test "renders the Project badge when the server belongs to a project", %{conn: conn} do + {:ok, p} = Projects.create_project(%{name: "blog", color: "emerald"}) + + {:ok, server} = + Fleet.create_server(%{name: "blog-prod-1", host: "10.0.0.80", project_id: p.id}) + + {:ok, _view, html} = live(conn, ~p"/servers/#{server}") + + assert html =~ "Project: blog" + assert html =~ "blog" + end + + test "omits the Project badge when the server has no project", %{conn: conn} do + {:ok, server} = Fleet.create_server(%{name: "lone", host: "10.0.0.81"}) + + {:ok, _view, html} = live(conn, ~p"/servers/#{server}") + + refute html =~ "Project: " + end + end + + describe "settings tab — project assignment" do + alias Mast.Fleet.Projects + + test "shows a Project dropdown on the settings tab", %{conn: conn} do + {:ok, server} = Fleet.create_server(%{name: "needs-proj", host: "10.0.0.70"}) + {:ok, _p} = Projects.create_project(%{name: "blog"}) + + {:ok, _view, html} = live(conn, ~p"/servers/#{server}?tab=settings") + + assert html =~ "Project" + assert html =~ "blog" + end + + test "assigning a project via the form emits an audit event", %{conn: conn} do + {:ok, server} = Fleet.create_server(%{name: "to-assign", host: "10.0.0.71"}) + {:ok, p} = Projects.create_project(%{name: "infra"}) + + {:ok, view, _html} = live(conn, ~p"/servers/#{server}?tab=settings") + + view + |> form("#server-project-form", server: %{project_id: p.id}) + |> render_submit() + + reloaded = Fleet.get_server!(server.id) + assert reloaded.project_id == p.id + + types = + "Server" + |> Mast.Audit.list_for_subject(server.id) + |> Enum.map(& &1.event_type) + + assert "server.project_assigned" in types + end + + test "unassigning by selecting the blank option clears project_id", %{conn: conn} do + {:ok, p} = Projects.create_project(%{name: "leaving"}) + + {:ok, server} = + Fleet.create_server(%{name: "to-unassign", host: "10.0.0.72", project_id: p.id}) + + {:ok, view, _html} = live(conn, ~p"/servers/#{server}?tab=settings") + + view + |> form("#server-project-form", server: %{project_id: ""}) + |> render_submit() + + assert Fleet.get_server!(server.id).project_id == nil + end + end end diff --git a/test/mast_web/live/settings_live_test.exs b/test/mast_web/live/settings_live_test.exs index 93f2d1f..95f3963 100644 --- a/test/mast_web/live/settings_live_test.exs +++ b/test/mast_web/live/settings_live_test.exs @@ -4,6 +4,7 @@ defmodule MastWeb.SettingsLiveTest do import Phoenix.LiveViewTest alias Mast.{Fleet, Keys} + alias Mast.Fleet.Projects defp pem(name), do: File.read!("test/fixtures/#{name}") @@ -60,4 +61,73 @@ defmodule MastWeb.SettingsLiveTest do assert Keys.list_keys() == [] end end + + describe "projects tab" do + test "renders the Projects subtab in the header", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/settings?tab=projects") + + assert html =~ "Projects" + assert html =~ "Manage your Mast configuration" + end + + test "lists existing projects with a server-usage count", %{conn: conn} do + {:ok, p} = Projects.create_project(%{name: "blog", color: "emerald"}) + + {:ok, _s} = + Fleet.create_server(%{name: "blog-prod-1", host: "10.0.0.91", project_id: p.id}) + + {:ok, _view, html} = live(conn, ~p"/settings?tab=projects") + + assert html =~ "blog" + assert html =~ "1 server" + end + + test "inline Add Project form creates a project", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/settings?tab=projects") + + view |> element("button", "Add Project") |> render_click() + + html = + view + |> form("#new-project-form", + project: %{name: "platform", description: "shared infra", color: "indigo"} + ) + |> render_submit() + + assert html =~ "platform" + assert [%{name: "platform", color: "indigo"}] = Projects.list_projects() + end + + test "rename inline edits a project name and emits project.renamed", %{conn: conn} do + {:ok, p} = Projects.create_project(%{name: "old-name"}) + + {:ok, view, _html} = live(conn, ~p"/settings?tab=projects") + + view + |> element("button[phx-value-id='#{p.id}'][phx-click='edit-project']") + |> render_click() + + view + |> form("#edit-project-form-#{p.id}", + project: %{name: "new-name", color: "rose"} + ) + |> render_submit() + + reloaded = Projects.get_project!(p.id) + assert reloaded.name == "new-name" + assert reloaded.color == "rose" + end + + test "delete removes a project", %{conn: conn} do + {:ok, p} = Projects.create_project(%{name: "to-delete"}) + + {:ok, view, _html} = live(conn, ~p"/settings?tab=projects") + + view + |> element("button[phx-value-id='#{p.id}'][phx-click='delete-project']") + |> render_click() + + assert Projects.list_projects() == [] + end + end end