From 1bc1a657920a42b03c63920cb42a66095397d1a0 Mon Sep 17 00:00:00 2001 From: Pachev Joseph Date: Sat, 23 May 2026 14:32:45 -0500 Subject: [PATCH 01/10] feat(projects): domain context for grouping servers - Migration: enable citext; create projects (binary_id, unique case- insensitive name, optional description/color); add nullable servers.project_id with on_delete: :nilify_all and index. - Schema Mast.Fleet.Project with color preset whitelist (slate, indigo, emerald, amber, rose, violet), trimmed name, length validation. - Context Mast.Fleet.Projects: list/get/create/update/delete plus assign_server/unassign_server. Every write goes through Multi and Audit.multi_log; rename and recolor each emit their own audit event only when the field actually changes. delete_project cascades into one server.project_unassigned per affected server inside the same transaction. - Server schema gains belongs_to :project and accepts :project_id on the changeset with a foreign-key constraint. Refs #24 --- lib/mast/fleet/project.ex | 42 ++++ lib/mast/fleet/projects.ex | 220 ++++++++++++++++++ lib/mast/fleet/server.ex | 4 +- .../20260523192231_create_projects.exs | 24 ++ test/mast/fleet/projects_test.exs | 202 ++++++++++++++++ 5 files changed, 491 insertions(+), 1 deletion(-) create mode 100644 lib/mast/fleet/project.ex create mode 100644 lib/mast/fleet/projects.ex create mode 100644 priv/repo/migrations/20260523192231_create_projects.exs create mode 100644 test/mast/fleet/projects_test.exs 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/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 From 7b3c02261c50b9f2a17d8b74ad40f0e9d5a60c24 Mon Sep 17 00:00:00 2001 From: Pachev Joseph Date: Sat, 23 May 2026 14:32:53 -0500 Subject: [PATCH 02/10] docs(ui): require responsive new components + sync pen with Projects frames - CLAUDE.md UI section: any new helper in lib/mast_web/components/ui/* must be responsive across the full breakpoint scale on day one, and added to /dev/ui for multi-width preview. Patching to responsiveness later is a sweep we have already done twice. - components.pen: pulled the Projects updates from main (new frame 'Fleet Overview - Projects' and new reusable 'ProjectGroup/Header'). Refs #24 --- CLAUDE.md | 10 + components.pen | 1770 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 1764 insertions(+), 16 deletions(-) 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/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": { From 9cb71ddbc606c5225acd04d20811cdc63dd7c1da Mon Sep 17 00:00:00 2001 From: Pachev Joseph Date: Sat, 23 May 2026 14:45:45 -0500 Subject: [PATCH 03/10] feat(projects): settings tab for list, create, rename, recolor, delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New tab 'Projects' on SettingsLive mirrors the SSH Keys pattern: inline add form, list with color swatch + server count, inline edit (rename + recolor + description), delete with confirm. - ProjectsTab template extracted to MastWeb.SettingsLive.ProjectsTab so SettingsLive stays under the ~500 LOC house limit. - Responsive by default: flex-wrap headers, sm: padding scales, min-w-0 + truncate on names, ≥40px tap targets for icon buttons, color picker wraps on narrow viewports. - color_swatch/1 helper maps preset colors to bg-* classes; reused by Slice 4 (Fleet group header + Server badge). Refs #24 --- lib/mast_web/live/settings_live.ex | 130 +++++++++- .../live/settings_live/projects_tab.ex | 235 ++++++++++++++++++ test/mast_web/live/settings_live_test.exs | 70 ++++++ 3 files changed, 433 insertions(+), 2 deletions(-) create mode 100644 lib/mast_web/live/settings_live/projects_tab.ex 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..1d34cca --- /dev/null +++ b/lib/mast_web/live/settings_live/projects_tab.ex @@ -0,0 +1,235 @@ +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"> + {if @show_new_project, do: "Cancel", else: "Add Project"} + +
+ +
+ <.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]} /> +
+ <.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/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 From 3dd144a593238e3ed27bd678fc752d220cca8792 Mon Sep 17 00:00:00 2001 From: Pachev Joseph Date: Sat, 23 May 2026 14:56:49 -0500 Subject: [PATCH 04/10] feat(projects): wire Project into Add Server modal + Server settings Add Server modal (DashboardLive): - Project select with inline 'Add new project' affordance, mirroring the SSH key flow. Saving a new project selects it on the server form automatically. - Generalized server_form_with/3 helper (was server_form_with_key/2). Server settings tab (ServerLive): - New 'Project' card with a Save-on-submit dropdown. Reassignment routes through Projects.assign_server/2 or unassign_server/1, which fire the server.project_(un)assigned audit events. Blank selection unassigns. Same-value selection is a no-op. Tests: - 4 new DashboardLive tests (Project dropdown, submit with project_id, inline create toggle, inline create selects the new project). - 3 new ServerLive tests (settings dropdown present, assign emits audit event, blank value unassigns). Refs #24 --- assets/package-lock.json | 9 +- lib/mast_web/live/dashboard_live.ex | 105 +++++++++++++++++- lib/mast_web/live/server_live.ex | 39 ++++++- lib/mast_web/live/server_live/settings_tab.ex | 25 +++++ test/mast_web/live/dashboard_live_test.exs | 62 +++++++++++ test/mast_web/live/server_live_test.exs | 50 +++++++++ 6 files changed, 279 insertions(+), 11 deletions(-) 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/lib/mast_web/live/dashboard_live.ex b/lib/mast_web/live/dashboard_live.ex index a71fbee..6e703d7 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,12 @@ defmodule MastWeb.DashboardLive do |> assign(:filter, "") |> assign(:servers, servers) |> assign(:keys, []) + |> assign(:projects, []) |> 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 +56,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 +66,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 @@ -135,7 +143,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,6 +157,52 @@ 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 @@ -252,6 +306,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 +351,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 @@ -334,11 +429,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/server_live.ex b/lib/mast_web/live/server_live.ex index 10638c7..e36dfd3 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,8 @@ defmodule MastWeb.ServerLive do |> assign(:show_system_apps?, false) |> assign(:confirm_delete?, false) |> assign(:confirm_name, "") + |> assign(:projects, Projects.list_projects()) + |> assign(:project_form, project_form(server)) |> assign(:updates_page, 1) |> assign(:updates_page_size, 10) |> assign(:updates_filter, "") @@ -348,6 +350,39 @@ 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_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 enqueue_apply(socket, extra) do args = Map.merge(extra, %{ @@ -447,6 +482,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/settings_tab.ex b/lib/mast_web/live/server_live/settings_tab.ex index 03c1b66..e75c0e5 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,29 @@ 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="flex items-end gap-2 flex-wrap" + > + <.input + field={@project_form[:project_id]} + type="select" + label="Project" + prompt="— none —" + options={Enum.map(@projects, &{&1.name, &1.id})} + class="min-w-[12rem] flex-1" + /> + <.ui_button type="submit" form="server-project-form">Save + +
+
diff --git a/test/mast_web/live/dashboard_live_test.exs b/test/mast_web/live/dashboard_live_test.exs index 8fc3bdb..1cb50ef 100644 --- a/test/mast_web/live/dashboard_live_test.exs +++ b/test/mast_web/live/dashboard_live_test.exs @@ -194,4 +194,66 @@ defmodule MastWeb.DashboardLiveTest do assert render(view) =~ "Fleet Overview" 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/server_live_test.exs b/test/mast_web/live/server_live_test.exs index d4c6d8c..4221189 100644 --- a/test/mast_web/live/server_live_test.exs +++ b/test/mast_web/live/server_live_test.exs @@ -314,4 +314,54 @@ defmodule MastWeb.ServerLiveTest do refute html =~ "ghost" 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 From ccca06510ed57b7d7359694e45a601efb52935d1 Mon Sep 17 00:00:00 2001 From: Pachev Joseph Date: Sat, 23 May 2026 15:02:47 -0500 Subject: [PATCH 05/10] feat(projects): fleet grouping + Server detail Project badge New UI helpers in MastWeb.Components.UI.Domain: - ui_project_group_header: chevron + colored dot + name + 'N servers' count. Pass a phx-click map via :toggle to make it collapsible; static when toggle is empty. Matches ProjectGroup/Header pen comp. - ui_project_badge: small color-tinted chip used near the Server detail header and elsewhere. Title attribute reads 'Project: '. - project_color_bg/1 helper maps preset colors to bg-* classes. Fleet page (DashboardLive): - Servers belonging to a Project render under their group header; ungrouped servers render as plain cards alongside. No 'Unassigned' pseudo-group. Per-group expand/collapse tracked in a MapSet. Server detail (ServerLive.Header): - Renders the Project badge next to the page title when set. - ServerLive loads the project on mount and re-loads it on update-project so the badge reflects assignment changes immediately. DevUi (/dev/ui): - Adds preview cards for the two new components so engineers can eyeball them at every breakpoint via the existing width toggle. Tests: - Fleet grouping: header renders with member count; ungrouped server still shows; no 'Unassigned' string; collapse toggle removes the cards. - Server header: badge renders 'Project: ' when assigned; absent otherwise. - DevUi smoke: both new previews appear on the page. Refs #24 --- lib/mast_web/components/ui.ex | 2 +- lib/mast_web/components/ui/domain.ex | 117 +++++++++++++++++++++ lib/mast_web/live/dashboard_live.ex | 57 ++++++++-- lib/mast_web/live/dev_ui_live/view.ex | 20 ++++ lib/mast_web/live/server_live.ex | 6 ++ lib/mast_web/live/server_live/header.ex | 2 + test/mast_web/live/dashboard_live_test.exs | 42 ++++++++ test/mast_web/live/dev_ui_live_test.exs | 9 ++ test/mast_web/live/server_live_test.exs | 24 +++++ 9 files changed, 272 insertions(+), 7 deletions(-) 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..31b0af1 100644 --- a/lib/mast_web/components/ui/domain.ex +++ b/lib/mast_web/components/ui/domain.ex @@ -356,4 +356,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 6e703d7..fcb8c06 100644 --- a/lib/mast_web/live/dashboard_live.ex +++ b/lib/mast_web/live/dashboard_live.ex @@ -19,7 +19,8 @@ defmodule MastWeb.DashboardLive do |> assign(:filter, "") |> assign(:servers, servers) |> assign(:keys, []) - |> assign(:projects, []) + |> assign(:projects, Projects.list_projects()) + |> assign(:collapsed_groups, MapSet.new()) |> assign(:form, nil) |> assign(:key_form, nil) |> assign(:show_new_key, false) @@ -107,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 @@ -209,11 +221,14 @@ defmodule MastWeb.DashboardLive do 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""" @@ -262,11 +277,29 @@ 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} /> +
+
+ +
+ <.ui_server_card :for={s <- @ungrouped} server={s} /> +
@@ -400,6 +433,18 @@ 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), diff --git a/lib/mast_web/live/dev_ui_live/view.ex b/lib/mast_web/live/dev_ui_live/view.ex index 6f7009e..bfb38a8 100644 --- a/lib/mast_web/live/dev_ui_live/view.ex +++ b/lib/mast_web/live/dev_ui_live/view.ex @@ -407,6 +407,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 e36dfd3..1d30155 100644 --- a/lib/mast_web/live/server_live.ex +++ b/lib/mast_web/live/server_live.ex @@ -49,6 +49,7 @@ defmodule MastWeb.ServerLive do |> 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) @@ -366,6 +367,7 @@ defmodule MastWeb.ServerLive do {: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.")} @@ -383,6 +385,9 @@ defmodule MastWeb.ServerLive 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, %{ @@ -446,6 +451,7 @@ defmodule MastWeb.ServerLive do running?={@running?} probing?={@probing?} scan_error={@scan_error} + project={@project} /> <%= case @tab do %> 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/test/mast_web/live/dashboard_live_test.exs b/test/mast_web/live/dashboard_live_test.exs index 1cb50ef..57b75f5 100644 --- a/test/mast_web/live/dashboard_live_test.exs +++ b/test/mast_web/live/dashboard_live_test.exs @@ -195,6 +195,48 @@ defmodule MastWeb.DashboardLiveTest do 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 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 4221189..cb3392b 100644 --- a/test/mast_web/live/server_live_test.exs +++ b/test/mast_web/live/server_live_test.exs @@ -315,6 +315,30 @@ defmodule MastWeb.ServerLiveTest do 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 From 05efb55679f404215e0e355cd6eef9d7d3bae637 Mon Sep 17 00:00:00 2001 From: Pachev Joseph Date: Sat, 23 May 2026 15:03:35 -0500 Subject: [PATCH 06/10] docs(changelog): record Projects feature under Unreleased Refs #24 --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 From 1b320412c9eff4c8c20e76c76da716eb26bbc5b7 Mon Sep 17 00:00:00 2001 From: Pachev Joseph Date: Sat, 23 May 2026 15:07:27 -0500 Subject: [PATCH 07/10] fix(projects): modal-ize Add Project and breathe space into Server Project card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Projects: - Convert the inline Add Project panel to a ui_modal mirroring the Add Server pattern (title + subtitle, footer with Cancel + Save). Avoids the awkward overlap where the empty state showed under an open create form on the same page. Server detail → Settings → Project card: - Title + description block at the top, full-width select on its own row, Save button anchored bottom-right. The previous flex-end row collided the label, select, and button at row height ~32px. Refs #24 --- lib/mast_web/live/server_live/settings_tab.ex | 22 ++++++++++------ .../live/settings_live/projects_tab.ex | 25 +++++++++++-------- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/lib/mast_web/live/server_live/settings_tab.ex b/lib/mast_web/live/server_live/settings_tab.ex index e75c0e5..9adb64b 100644 --- a/lib/mast_web/live/server_live/settings_tab.ex +++ b/lib/mast_web/live/server_live/settings_tab.ex @@ -23,16 +23,21 @@ 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. -

+
+
+
+

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="flex items-end gap-2 flex-wrap" + class="space-y-4" > <.input field={@project_form[:project_id]} @@ -40,9 +45,10 @@ defmodule MastWeb.ServerLive.SettingsTab do label="Project" prompt="— none —" options={Enum.map(@projects, &{&1.name, &1.id})} - class="min-w-[12rem] flex-1" /> - <.ui_button type="submit" form="server-project-form">Save +
+ <.ui_button type="submit" form="server-project-form">Save +
diff --git a/lib/mast_web/live/settings_live/projects_tab.ex b/lib/mast_web/live/settings_live/projects_tab.ex index 1d34cca..b55c115 100644 --- a/lib/mast_web/live/settings_live/projects_tab.ex +++ b/lib/mast_web/live/settings_live/projects_tab.ex @@ -26,14 +26,18 @@ defmodule MastWeb.SettingsLive.ProjectsTab do <.ui_button icon="hero-plus" size="sm" phx-click="toggle-new-project"> - {if @show_new_project, do: "Cancel", else: "Add Project"} + Add Project
-
+ <:title>Add a project + <:subtitle>Group servers under a shared label. + <.form for={@project_form} id="new-project-form" @@ -53,14 +57,15 @@ defmodule MastWeb.SettingsLive.ProjectsTab do placeholder="Customer-facing blog stack" /> <.color_picker field={@project_form[:color]} /> -
- <.ui_button type="button" variant="secondary" phx-click="toggle-new-project"> - Cancel - - <.ui_button type="submit" form="new-project-form">Save project -
-
+ + <: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 %>
From d3a679eb6012269b5b972820c196facdbe2b5830 Mon Sep 17 00:00:00 2001 From: Pachev Joseph Date: Sat, 23 May 2026 15:11:51 -0500 Subject: [PATCH 08/10] fix(fleet): Releases stat tile + enriched server card to match pen Swap the 4th Fleet Overview stat tile from 'Seen Recently' to 'Releases' (count of all Release rows across the fleet), matching the 'Fleet Overview - Projects' pen frame. Adds Fleet.count_all_releases/0. Rebuild ui_server_card to match Card/Server in the same pen frame: - Server icon tile + name + host stacked top-left, status badge top-right - Meta line with OS + last-seen relative - CPU / MEM / DISK in a 3-col grid with labeled bars instead of stacked DevUi sample server gets host, os_id, and last_seen_at so the preview renders the new chrome. Refs #24 --- lib/mast/fleet.ex | 5 ++++ lib/mast_web/components/ui/domain.ex | 43 ++++++++++++++++++++++----- lib/mast_web/live/dashboard_live.ex | 10 ++----- lib/mast_web/live/dev_ui_live/view.ex | 5 +++- 4 files changed, 47 insertions(+), 16 deletions(-) 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_web/components/ui/domain.ex b/lib/mast_web/components/ui/domain.ex index 31b0af1..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" diff --git a/lib/mast_web/live/dashboard_live.ex b/lib/mast_web/live/dashboard_live.ex index fcb8c06..4fd042e 100644 --- a/lib/mast_web/live/dashboard_live.ex +++ b/lib/mast_web/live/dashboard_live.ex @@ -252,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} />
@@ -450,16 +450,10 @@ defmodule MastWeb.DashboardLive 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 diff --git a/lib/mast_web/live/dev_ui_live/view.ex b/lib/mast_web/live/dev_ui_live/view.ex index bfb38a8..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) From ee8192844137d5a963ad7c1f25dfdf14ab841e00 Mon Sep 17 00:00:00 2001 From: Pachev Joseph Date: Sat, 23 May 2026 15:15:00 -0500 Subject: [PATCH 09/10] fix(fleet): separate ungrouped servers with a divider + label When at least one Project group renders, the ungrouped server cards flowed inline below the last group with no visual break, so a server that belonged to no project looked like a member of the last group. Add a horizontal divider with an 'Ungrouped' label and count above the orphan grid, but only when groups are also present. Pure ungrouped fleets (no projects assigned anywhere) keep the clean unlabeled grid. Refs #24 --- lib/mast_web/live/dashboard_live.ex | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/lib/mast_web/live/dashboard_live.ex b/lib/mast_web/live/dashboard_live.ex index 4fd042e..094bf0b 100644 --- a/lib/mast_web/live/dashboard_live.ex +++ b/lib/mast_web/live/dashboard_live.ex @@ -294,11 +294,22 @@ defmodule MastWeb.DashboardLive do
-
- <.ui_server_card :for={s <- @ungrouped} server={s} /> +
+
+ + Ungrouped + + + + {length(@ungrouped)} {if length(@ungrouped) == 1, do: "server", else: "servers"} + +
+
+ <.ui_server_card :for={s <- @ungrouped} server={s} /> +
From fc7fc224bb9bb66ab69767c7fe05322adffabcbc Mon Sep 17 00:00:00 2001 From: Pachev Joseph Date: Sat, 23 May 2026 15:17:15 -0500 Subject: [PATCH 10/10] fix(fleet): drop harsh border on Ungrouped section header The border-t divider looked heavy, especially in dark mode. Match the chrome of ui_project_group_header (label + count, no line) so the 'Ungrouped' header reads as the same kind of section header as a project group, just without a chevron or color dot. Refs #24 --- lib/mast_web/live/dashboard_live.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/mast_web/live/dashboard_live.ex b/lib/mast_web/live/dashboard_live.ex index 094bf0b..1a1440c 100644 --- a/lib/mast_web/live/dashboard_live.ex +++ b/lib/mast_web/live/dashboard_live.ex @@ -297,13 +297,13 @@ defmodule MastWeb.DashboardLive do
- + Ungrouped - - + + {length(@ungrouped)} {if length(@ungrouped) == 1, do: "server", else: "servers"}