Powered by AppSignal & Oban Pro

Delete Useless Apps

delete_useless_apps.livemd

Delete Useless Apps

Mix.install([
  {:req, "~> 0.5.0"},
  {:kino, "~> 0.17.0"},
  {:kino_clipboard, "~> 0.1.0"}
])

Build Combined Token

# Step 1: Read all LB_FLYIO_ env vars
raw_tokens =
  System.get_env()
  |> Enum.filter(fn {key, _} -> String.starts_with?(key, "LB_FLYIO_") end)
  |> Enum.sort_by(fn {key, _} -> key end)

IO.puts("Found #{length(raw_tokens)} LB_FLYIO_* env vars")
# Step 2: Strip "FlyV1 " prefix if present, then validate
processed_tokens =
  Enum.map(raw_tokens, fn {key, value} ->
    stripped =
      if String.starts_with?(value, "FlyV1 ") do
        String.replace_prefix(value, "FlyV1 ", "")
      else
        value
      end

    {key, stripped}
  end)

# Step 3: Raise if any token does not start with fm2_
invalid =
  processed_tokens
  |> Enum.filter(fn {_key, value} -> not String.starts_with?(value, "fm2_") end)
  |> Enum.map(fn {key, _} -> key end)

if invalid != [] do
  raise "The following environment variables have invalid tokens: #{Enum.join(invalid, ", ")}"
end

IO.puts("All #{length(processed_tokens)} tokens are valid ✓")
# Step 4: Build combined token — FlyV1 fm2_token1,fm2_token2,...
combined_token =
  processed_tokens
  |> Enum.map(fn {_key, value} -> value end)
  |> Enum.join(",")
  |> then(&"FlyV1 #{&1}")

IO.puts("Combined token built from #{length(processed_tokens)} tokens")

Fetch Organizations

# Step 5 & 6: Query organizations via GraphQL
gql_response =
  Req.post!(
    "https://api.fly.io/graphql",
    headers: [{"authorization", combined_token}],
    json: %{
      "query" => "query($admin: Boolean!) { organizations(admin: $admin) { nodes { rawSlug name } } }",
      "variables" => %{"admin" => false}
    }
  )

if errors = get_in(gql_response.body, ["errors"]) do
  raise "GraphQL errors: #{inspect(errors)}"
end

orgs = get_in(gql_response.body, ["data", "organizations", "nodes"])

IO.puts("Found #{length(orgs)} organizations:")
for %{"name" => name, "rawSlug" => slug} <- orgs, do: IO.puts("  • #{name} (#{slug})")

orgs

List All Apps

# Step 7: Fetch apps for every org and display a unified table
all_apps =
  orgs
  |> Enum.flat_map(fn %{"name" => org_name, "rawSlug" => org_slug} ->
    %{body: body} =
      Req.get!(
        url: "https://api.machines.dev/v1/apps?org_slug=#{org_slug}",
        headers: [{"authorization", combined_token}]
      )

    apps = body["apps"] || []

    Enum.map(apps, fn app ->
      %{
        "name" => app["name"],
        "organization" => org_name,
        "machine_count" => app["machine_count"]
      }
    end)
  end)
  |> Enum.sort_by(fn app -> {app["organization"], app["name"]} end)

IO.puts("Total apps: #{length(all_apps)}")

Kino.DataTable.new(all_apps, keys: ["name", "organization", "machine_count"])

Configure Deletion Prefixes

{:ok, prefix_agent} = Agent.start_link(fn -> ["hello-fly-"] end)

frame = Kino.Frame.new()

render_ui = fn render_ui, prefixes ->
  Agent.update(prefix_agent, fn _ -> prefixes end)

  add_form =
    Kino.Control.form(
      [new_prefix: Kino.Input.text("New prefix")],
      submit: "Add"
    )

  remove_btns =
    Enum.map(prefixes, fn prefix ->
      {prefix, Kino.Control.button("✕")}
    end)

  list_widget =
    if prefixes == [] do
      Kino.Markdown.new("_No prefixes added yet._")
    else
      rows =
        Enum.map(remove_btns, fn {prefix, btn} ->
          Kino.Layout.grid([Kino.Markdown.new("`#{prefix}`"), btn], columns: 2)
        end)

      Kino.Layout.grid(rows)
    end

  Kino.Frame.render(
    frame,
    Kino.Layout.grid([
      Kino.Markdown.new("**Prefixes to match for deletion:**"),
      list_widget,
      add_form
    ])
  )

  Kino.listen(add_form, fn %{data: %{new_prefix: raw}} ->
    new_prefix = String.trim(raw)
    current = Agent.get(prefix_agent, &amp; &amp;1)

    if new_prefix != "" and new_prefix not in current do
      render_ui.(render_ui, current ++ [new_prefix])
    end
  end)

  for {prefix, btn} <- remove_btns do
    Kino.listen(btn, fn _ ->
      current = Agent.get(prefix_agent, &amp; &amp;1)
      render_ui.(render_ui, List.delete(current, prefix))
    end)
  end
end

render_ui.(render_ui, ["hello-fly-"])
frame
# Read current prefixes from the form above
deletion_prefixes = Agent.get(prefix_agent, &amp; &amp;1)
destroy_commands =
  all_apps
  |> Enum.filter(fn %{"name" => name} ->
    Enum.any?(deletion_prefixes, &amp;String.starts_with?(name, &amp;1))
  end)
  |> Enum.map_join("\n", fn %{"name" => name} -> "fly apps destroy #{name} --yes" end)

IO.puts(destroy_commands)
KinoClipboard.new(fn -> destroy_commands end, label: "Copy destroy commands")