Skip to content

Workstation Administration

Users of Deskterm will need to be able to administrate and manage their workstations, including:

  • Shutting down running workstations
  • Deleting workstations
  • View their workstations in persistent storage
  • View their storage quota usage
  • View their monthly hours usage quota

Free users will not be able to do any of the above except for being able to shut down their workstation. For technical reasons, paid users can have only one workstation running at a time.

The workstations become laggy when they have to stream the screen at a too high resolution to clients. To prevent this lagginess, we will implement logic to render at a lower resolution in the workstation and upscale that on the client's web browser.

Architecture

Shut down running workstation

To allow users to shut down their running workstation, a widget will be added to the top of the screen with a Shut down button. When the button is pressed, the workstation is shut down in a similar way as is already the case when the user closes the browser tab or the 5 minute session limit expires for free users.

Deleting workstations

To delete a user's workstation, two things need to happen:

  • Delete the container
  • Delete the container image

In order to remove those from the user's volume, a Fly.io machine needs to be started and the volume attached to the machine. Therefore the workstation deletion process is actually quite heavy and can take anywhere between 10 and 30 seconds in total. The existing loading workstation view is re-used to show the user the deletion progress.

Persistent Workstations

Users can view their workstations stored in persistant storage on a dedicated view.

Usage Quotas

Users can view how much of their quotas they have used so far on the settings view. The quotas are:

  • Hours used so far this month
  • Used storage on the user's volume

To determine how full the user's volume is, the Fly.io API is used.

Screen Scaling

To prevent ugly scaling, we will only scale at a factor of whole numbers. This will ensure that every pixel in the workstation is mapped to multiple discrete pixels on the client device, without any pixels overlapping from the scaling. For example, if the user's client resolution is 3840x2160 (4K), then on the workstation we would render at 1920x1080 (1080P), which is exactly half the width/length of the client's device. We would be upscaling at a factor of 2, which results in 4 times more pixels (2 x 2).

Configuration

Continuous Integration

Update build-web.yml to stop treating warnings as errors so the build does not fail on them:

build-web.yml
    - mix compile

Tests

E2E Test

We will create an E2E test in Qase. Add a new test case to the Common suite named Workstation Administration:

Workstation Administration Test 1

Workstation Administration Test 2

Workstation Administration Test 3

Automated Tests

In the test/support directory, create a new file named session_utilities.ex and populate it with the below content:

session_utilities.ex
defmodule DesktermWeb.TestSessionUtilities do
  alias Deskterm.Profile
  alias Deskterm.Repo
  alias Deskterm.Schema.Profile, as: ProfileSchema
  alias Deskterm.Session
  alias Deskterm.VPNAccount
  alias DesktermWeb.TestUserAtoms
  alias DesktermWeb.TestVPNAtoms

  defmodule TimeSpan do
    defstruct [:started_at, :ended_at]
  end

  @ws_name "Chrome"

  def get_session(started_minutes_ago, user_type \\ "free_user") do
    started_at =
      DateTime.utc_now()
      |> DateTime.add(-started_minutes_ago, :minute)

    {:ok, vpn_account} =
      VPNAccount.create(%{
        hideme_uuid: Ecto.UUID.generate(),
        username: Ecto.UUID.generate(),
        password: TestVPNAtoms.password()
      })

    attributes = %{
      user_type: user_type,
      fly_app_name: "free-38c915ee0f38b834",
      ws_name: @ws_name,
      ip_address: "127.0.0.1",
      started_at: started_at,
      vpn_account_id: vpn_account.id
    }

    Session.create(attributes)
  end

  def create_profile_session(profile_id, %TimeSpan{} = time_span) do
    attributes = %{
      user_type: "old_user",
      fly_app_name: TestUserAtoms.fly_app_name(),
      ws_name: @ws_name,
      ip_address: "127.0.0.1",
      profile_id: profile_id,
      started_at: time_span.started_at,
      ended_at: time_span.ended_at
    }

    {:ok, session} = Session.create(attributes)
    session
  end

  def get_time_minutes_ago(minutes_ago) do
    DateTime.add(DateTime.utc_now(), -minutes_ago, :minute)
  end

  def get_time_hours_ago(hours_ago) do
    DateTime.add(DateTime.utc_now(), -hours_ago, :hour)
  end

  def create_profile do
    create_profile(Ecto.UUID.generate(), with_resources: false)
  end

  def create_profile(user_id, with_resources: false) do
    {:ok, profile} = Profile.ensure_profile(user_id)
    profile
  end

  def create_profile(user_id, with_resources: true) do
    {:ok, vpn_account} =
      VPNAccount.create(%{
        hideme_uuid: Ecto.UUID.generate(),
        username: Ecto.UUID.generate(),
        password: TestVPNAtoms.password()
      })

    {:ok, profile} = Profile.ensure_profile(user_id)

    {:ok, profile} =
      profile
      |> ProfileSchema.changeset(%{
        fly_app_name: TestUserAtoms.fly_app_name(),
        vpn_account_id: vpn_account.id
      })
      |> Repo.update()

    profile
  end

  def create_orphaned_profile(with_resources: with_resources?) do
    profile =
      create_profile(Ecto.UUID.generate(), with_resources: with_resources?)

    {:ok, profile} = Profile.mark_deleted(profile)
    profile
  end
end

Update app_utilities.ex to add a when_lock_is_scheduled helper for the lock heartbeat and make create_vpn_account public so it can be reused:

app_utilities.ex
  def when_lock_is_scheduled do
    expect(
      DesktermWeb.ElixirProcessAPIMock,
      :send_after,
      fn _,
         :refresh_lock,
         150_000 ->
        make_ref()
      end
    )
  end

  def create_vpn_account do
    Deskterm.VPNAccount.create(%{
      hideme_uuid: TestVPNAtoms.uuid(),
      username: TestVPNAtoms.username(),
      password: TestVPNAtoms.password()
    })
  end

Update user_atoms.ex to add a Fly volume id atom used by the storage quota tests:

user_atoms.ex
  @fly_volume_id "vol_CnGy1QUofswXZs6o"

  def fly_volume_id, do: @fly_volume_id

Update fly_utilities.ex to add helpers that stub the new get_volume Fly API call:

fly_utilities.ex
  def when_get_volume_fails do
    expect(
      DesktermWeb.FlyAPIMock,
      :get_volume,
      fn _app_name, _volume_id ->
        :error
      end
    )
  end

  def when_get_volume_returns_botched_response do
    expect(
      DesktermWeb.FlyAPIMock,
      :get_volume,
      fn _app_name, _volume_id ->
        {:ok, %{}}
      end
    )
  end

  def when_get_volume_succeeds(bytes_used \\ 0) do
    expect(
      DesktermWeb.FlyAPIMock,
      :get_volume,
      fn _app_name, _volume_id ->
        {:ok,
         %{
           "block_size" => 4096,
           "blocks" => 10_485_760,
           "blocks_free" => 10_485_760 - div(bytes_used, 4096)
         }}
      end
    )
  end

Update launch_utilities.ex to add assertions for the deletion and shut down flows:

launch_utilities.ex
  def assert_deletion_aborted(view) do
    assert_push_event(view, "show-toast", %{})
    assert has_element?(view, "#overview")
    assert has_element?(view, "#user-workstations")
    assert has_element?(view, "#toast-error")
    refute has_element?(view, "#loading-workstation")
  end

  def assert_deletion_not_aborted(view) do
    assert_push_event(view, "show-toast", %{})
    assert has_element?(view, "#overview")
    assert has_element?(view, "#user-workstations")
    assert has_element?(view, "#toast-info")
    refute has_element?(view, "#loading-workstation")
  end

  def assert_workstation_shut_down(view) do
    assert_push_event(view, "show-toast", %{})
    assert has_element?(view, "#overview")
    assert has_element?(view, "#toast-info")
    refute has_element?(view, "#loading-workstation")
  end

In the test/deskterm directory, create a new file named profile_cleanup_test.exs and populate it with the below content:

profile_cleanup_test.exs
defmodule Deskterm.ProfileCleanupTest do
  use Deskterm.DataCase, async: true

  import Mox

  alias Deskterm.ProfileCleanup
  alias Deskterm.VPNAccount
  alias DesktermWeb.TestAppUtilities
  alias DesktermWeb.TestFlyUtilities
  alias DesktermWeb.TestHideMeUtilities
  alias DesktermWeb.TestUserAtoms

  setup :verify_on_exit!

  test "returns :error when deleting the Fly app fails" do
    TestFlyUtilities.when_delete_app_fails()

    assert :error = ProfileCleanup.delete_fly_app(TestUserAtoms.fly_app_name())
  end

  test "returns :ok when deleting the Fly app succeeds" do
    TestFlyUtilities.when_delete_app_succeeds()

    assert :ok = ProfileCleanup.delete_fly_app(TestUserAtoms.fly_app_name())
  end

  test "does not mark VPN account as deleted when deletion fails" do
    TestHideMeUtilities.when_delete_account_fails()
    {:ok, vpn_account} = TestAppUtilities.create_vpn_account()

    assert :error = ProfileCleanup.delete_vpn_account(vpn_account.id)

    vpn_account = VPNAccount.get(vpn_account.id)
    assert vpn_account.deleted_at == nil
  end

  test "does not re-attempt deletion on already deleted VPN account" do
    {:ok, vpn_account} = TestAppUtilities.create_vpn_account()
    {:ok, _} = VPNAccount.delete(vpn_account)

    assert :ok = ProfileCleanup.delete_vpn_account(vpn_account.id)
  end

  test "marks VPN account as deleted on successful deletion" do
    TestHideMeUtilities.when_delete_account_succeeds()
    {:ok, vpn_account} = TestAppUtilities.create_vpn_account()

    assert :ok = ProfileCleanup.delete_vpn_account(vpn_account.id)

    vpn_account = VPNAccount.get(vpn_account.id)
    assert vpn_account.deleted_at != nil
  end
end

In the test/deskterm_web/live/workstation directory, create a new file named quota_test.exs and populate it with the below content:

quota_test.exs
defmodule DesktermWeb.Workstation.QuotaTest do
  use Deskterm.DataCase, async: true

  import Mox

  alias Deskterm.Schema.Profile, as: ProfileSchema
  alias DesktermWeb.TestFlyUtilities
  alias DesktermWeb.TestSessionUtilities, as: SessionUtilities
  alias DesktermWeb.TestSessionUtilities.TimeSpan
  alias DesktermWeb.TestUserAtoms
  alias DesktermWeb.Workstation.Utilities

  setup :verify_on_exit!

  @gb 1_073_741_824

  test "defaults used storage to zero when fetching volume data fails" do
    TestFlyUtilities.when_get_volume_fails()

    storage_usage_gb =
      Utilities.get_storage_usage(
        TestUserAtoms.fly_app_name(),
        TestUserAtoms.fly_volume_id()
      )

    assert storage_usage_gb == 0
  end

  test "defaults used storage to 0 when the volume response is botched" do
    TestFlyUtilities.when_get_volume_returns_botched_response()

    storage_usage_gb =
      Utilities.get_storage_usage(
        TestUserAtoms.fly_app_name(),
        TestUserAtoms.fly_volume_id()
      )

    assert storage_usage_gb == 0
  end

  test "used storage is correctly calculated when fetching volume data succeeds" do
    TestFlyUtilities.when_get_volume_succeeds(5 * @gb)

    storage_usage_gb =
      Utilities.get_storage_usage(
        TestUserAtoms.fly_app_name(),
        TestUserAtoms.fly_volume_id()
      )

    assert storage_usage_gb == 5
  end

  test "used storage is rounded down" do
    TestFlyUtilities.when_get_volume_succeeds(@gb - 1)

    storage_usage_gb =
      Utilities.get_storage_usage(
        TestUserAtoms.fly_app_name(),
        TestUserAtoms.fly_volume_id()
      )

    assert storage_usage_gb == 0
  end

  test "defaults used hours to zero when user is not subscribed" do
    used_hours =
      Utilities.get_used_hours(%ProfileSchema{subscription_start: nil})

    assert used_hours == 0
  end

  test "rounds down used hours" do
    profile = create_profile(30 * 24)

    SessionUtilities.create_profile_session(profile.id, %TimeSpan{
      started_at: SessionUtilities.get_time_hours_ago(15 * 24),
      ended_at: SessionUtilities.get_time_minutes_ago(60 * 14 * 24 + 1)
    })

    used_hours = Utilities.get_used_hours(profile)

    assert used_hours == 23
  end

  defp create_profile(age_in_hours) do
    profile = SessionUtilities.create_profile()

    {:ok, profile} =
      profile
      |> ProfileSchema.changeset(%{
        subscription_start: SessionUtilities.get_time_hours_ago(age_in_hours)
      })
      |> Repo.update()

    profile
  end
end

In the test/deskterm_web/live/workstation directory, create a new file named workstation_deletion_test.exs and populate it with the below content:

workstation_deletion_test.exs
defmodule DesktermWeb.WorkstationDeletionTest do
  use DesktermWeb.ConnCase

  import Mox
  import Phoenix.LiveViewTest

  alias Deskterm.Repo
  alias Deskterm.Schema.Workstation, as: WorkstationSchema
  alias DesktermWeb.TestAppUtilities
  alias DesktermWeb.TestFlyUtilities
  alias DesktermWeb.TestLaunchUtilities
  alias DesktermWeb.Workstation.DeletionModal

  setup :verify_on_exit!

  test "aborts deletion when starting machine fails", %{conn: conn} do
    view = TestAppUtilities.when_is_old_user(conn, has_workstation: true)

    TestFlyUtilities.when_start_machine_fails()
    TestFlyUtilities.when_stop_machine_succeeds()

    render_click(view, "delete_workstation", %{ws_name: "Chrome"})
    render_click(view, DeletionModal.delete_ws_modal_event(), %{})

    TestLaunchUtilities.assert_deletion_aborted(view)
    assert Repo.aggregate(WorkstationSchema, :count) == 1
  end

  test "aborts deletion when waiting for machine fails", %{conn: conn} do
    view = TestAppUtilities.when_is_old_user(conn, has_workstation: true)

    TestFlyUtilities.when_start_machine_succeeds()
    TestFlyUtilities.when_wait_for_machine_fails()
    TestFlyUtilities.when_stop_machine_succeeds()

    render_click(view, "delete_workstation", %{ws_name: "Chrome"})
    render_click(view, DeletionModal.delete_ws_modal_event(), %{})

    TestLaunchUtilities.assert_deletion_aborted(view)
    assert Repo.aggregate(WorkstationSchema, :count) == 1
  end

  test "aborts deletion when waiting for docker fails", %{conn: conn} do
    view = TestAppUtilities.when_is_old_user(conn, has_workstation: true)

    TestFlyUtilities.when_start_machine_succeeds()
    TestFlyUtilities.when_wait_for_machine_succeeds()
    TestFlyUtilities.when_execute_command_fails()
    TestFlyUtilities.when_stop_machine_succeeds()

    render_click(view, "delete_workstation", %{ws_name: "Chrome"})
    render_click(view, DeletionModal.delete_ws_modal_event(), %{})

    TestLaunchUtilities.assert_deletion_aborted(view)
    assert Repo.aggregate(WorkstationSchema, :count) == 1
  end

  test "aborts deletion when nuking workstation fails", %{conn: conn} do
    view = TestAppUtilities.when_is_old_user(conn, has_workstation: true)

    TestFlyUtilities.when_start_machine_succeeds()
    TestFlyUtilities.when_wait_for_machine_succeeds()
    TestFlyUtilities.when_execute_command_completes()
    TestFlyUtilities.when_execute_command_fails()
    TestFlyUtilities.when_stop_machine_succeeds()

    render_click(view, "delete_workstation", %{ws_name: "Chrome"})
    render_click(view, DeletionModal.delete_ws_modal_event(), %{})

    TestLaunchUtilities.assert_deletion_aborted(view)
    assert Repo.aggregate(WorkstationSchema, :count) == 1
  end

  test "aborts deletion when stopping machine fails", %{conn: conn} do
    view = TestAppUtilities.when_is_old_user(conn, has_workstation: true)

    TestFlyUtilities.when_start_machine_succeeds()
    TestFlyUtilities.when_wait_for_machine_succeeds()
    TestFlyUtilities.when_execute_command_completes(2)
    TestFlyUtilities.when_stop_machine_fails()
    TestFlyUtilities.when_stop_machine_succeeds()

    render_click(view, "delete_workstation", %{ws_name: "Chrome"})
    render_click(view, DeletionModal.delete_ws_modal_event(), %{})

    TestLaunchUtilities.assert_deletion_aborted(view)
    assert Repo.aggregate(WorkstationSchema, :count) == 1
  end

  test "does not abort launch when deletion flow succeeds", %{conn: conn} do
    view = TestAppUtilities.when_is_old_user(conn, has_workstation: true)

    TestFlyUtilities.when_start_machine_succeeds()
    TestFlyUtilities.when_wait_for_machine_succeeds()
    TestFlyUtilities.when_execute_command_completes(2)
    TestFlyUtilities.when_stop_machine_succeeds()

    render_click(view, "delete_workstation", %{ws_name: "Chrome"})
    render_click(view, DeletionModal.delete_ws_modal_event(), %{})

    TestLaunchUtilities.assert_deletion_not_aborted(view)
    assert Repo.aggregate(WorkstationSchema, :count) == 0
  end
end

In the test/deskterm_web/live/workstation directory, create a new file named workstation_shutdown_test.exs and populate it with the below content:

workstation_shutdown_test.exs
defmodule DesktermWeb.WorkstationShutdownTest do
  use DesktermWeb.ConnCase

  import Mox
  import Phoenix.LiveViewTest

  alias Deskterm.Repo
  alias Deskterm.Schema.Profile, as: ProfileSchema
  alias Deskterm.Schema.Session, as: SessionSchema
  alias DesktermWeb.TestAppUtilities
  alias DesktermWeb.TestFlyUtilities
  alias DesktermWeb.TestHideMeUtilities
  alias DesktermWeb.TestLaunchUtilities
  alias DesktermWeb.TestUserAtoms

  setup :verify_on_exit!

  test "shows success when free user shuts down workstation",
       %{conn: conn} do
    TestFlyUtilities.when_fly_app_is_created()
    TestFlyUtilities.when_machine_is_created()
    TestFlyUtilities.when_execute_command_completes(7)
    TestHideMeUtilities.when_create_account_succeeds()
    TestHideMeUtilities.when_delete_account_succeeds()
    TestFlyUtilities.when_delete_app_succeeds()
    view = TestAppUtilities.when_is_unauthenticated(conn)
    TestLaunchUtilities.start_workstation(view)

    render_click(view, "shut_down_workstation")

    TestLaunchUtilities.assert_workstation_shut_down(view)
    assert Repo.one(SessionSchema).ended_at != nil
  end

  test "shows success when paid user shuts down workstation",
       %{conn: conn} do
    TestFlyUtilities.when_start_machine_succeeds()
    TestFlyUtilities.when_wait_for_machine_succeeds()
    TestFlyUtilities.when_execute_command_completes(6)
    TestFlyUtilities.when_execute_command_succeeds()
    view = TestAppUtilities.when_is_old_user(conn, has_vpn_account: true)
    TestAppUtilities.when_lock_is_scheduled()
    TestAppUtilities.when_message_is_cancelled()
    TestLaunchUtilities.start_workstation(view)
    render_click(view, "shut_down_workstation")

    TestLaunchUtilities.assert_workstation_shut_down(view)
    profile = Repo.get_by!(ProfileSchema, user_id: TestUserAtoms.user_id())
    assert profile.locked_at == nil
    assert Repo.one(SessionSchema).ended_at != nil
  end
end

In the test/assets/test directory, create a new file named scale-screen.test.ts that verifies scaleScreen only applies an integer --ws-scale factor above the QHD+ threshold and accounts for the device pixel ratio. It covers a range of 16:10, 16:9 and 21:9 resolutions as well as high-DPI displays:

scale-screen.test.ts
import "@testing-library/jest-dom";

import { scaleScreen } from "../ts/workstation/scale-screen";

type Viewport = {
  width: string;
  height: string;
  devicePixelRatio: number | undefined;
};

describe("Scale Screen", () => {
  const SCALE_PROPERTY = "--ws-scale";

  let iFrame: HTMLIFrameElement;

  function setViewport(viewport: Viewport): void {
    Object.defineProperty(window, "innerWidth", {
      configurable: true,
      value: viewport.width,
    });
    Object.defineProperty(window, "innerHeight", {
      configurable: true,
      value: viewport.height,
    });
    Object.defineProperty(window, "devicePixelRatio", {
      configurable: true,
      value: viewport.devicePixelRatio,
    });
  }

  beforeEach(() => {
    document.body.innerHTML = `<iframe id="ws-iframe"></iframe>`;
    iFrame = document.getElementById("ws-iframe") as HTMLIFrameElement;
  });

  // 16:10
  test("does not scale WXGA", () => {
    const viewport: Viewport = { width: "1280", height: "800", devicePixelRatio: 1 };
    setViewport(viewport);

    scaleScreen(iFrame);

    expect(iFrame.style.getPropertyValue(SCALE_PROPERTY)).toBe("");
  });

  test("does not scale WXGA+", () => {
    const viewport: Viewport = { width: "1440", height: "900", devicePixelRatio: 1 };
    setViewport(viewport);

    scaleScreen(iFrame);

    expect(iFrame.style.getPropertyValue(SCALE_PROPERTY)).toBe("");
  });

  test("does not scale WSXGA+", () => {
    const viewport: Viewport = { width: "1680", height: "1050", devicePixelRatio: 1 };
    setViewport(viewport);

    scaleScreen(iFrame);

    expect(iFrame.style.getPropertyValue(SCALE_PROPERTY)).toBe("");
  });

  test("does not scale WUXGA", () => {
    const viewport: Viewport = { width: "1920", height: "1200", devicePixelRatio: 1 };
    setViewport(viewport);

    scaleScreen(iFrame);

    expect(iFrame.style.getPropertyValue(SCALE_PROPERTY)).toBe("");
  });

  test("does not scale WQXGA", () => {
    const viewport: Viewport = { width: "2560", height: "1600", devicePixelRatio: 1 };
    setViewport(viewport);

    scaleScreen(iFrame);

    expect(iFrame.style.getPropertyValue(SCALE_PROPERTY)).toBe("");
  });

  test("scales WQXGA+", () => {
    const viewport: Viewport = { width: "2880", height: "1800", devicePixelRatio: 1 };
    setViewport(viewport);

    scaleScreen(iFrame);

    expect(iFrame.style.getPropertyValue(SCALE_PROPERTY)).toBe("2");
  });

  test("scales WQUXGA", () => {
    const viewport: Viewport = { width: "3840", height: "2400", devicePixelRatio: 1 };
    setViewport(viewport);

    scaleScreen(iFrame);

    expect(iFrame.style.getPropertyValue(SCALE_PROPERTY)).toBe("2");
  });

  // 16:9
  test("does not scale HD 720p", () => {
    const viewport: Viewport = { width: "1280", height: "720", devicePixelRatio: 1 };
    setViewport(viewport);

    scaleScreen(iFrame);

    expect(iFrame.style.getPropertyValue(SCALE_PROPERTY)).toBe("");
  });

  test("does not scale FWXGA", () => {
    const viewport: Viewport = { width: "1366", height: "768", devicePixelRatio: 1 };
    setViewport(viewport);

    scaleScreen(iFrame);

    expect(iFrame.style.getPropertyValue(SCALE_PROPERTY)).toBe("");
  });

  test("does not scale WSXGA", () => {
    const viewport: Viewport = { width: "1600", height: "900", devicePixelRatio: 1 };
    setViewport(viewport);

    scaleScreen(iFrame);

    expect(iFrame.style.getPropertyValue(SCALE_PROPERTY)).toBe("");
  });

  test("does not scale Full HD", () => {
    const viewport: Viewport = { width: "1920", height: "1080", devicePixelRatio: 1 };
    setViewport(viewport);

    scaleScreen(iFrame);

    expect(iFrame.style.getPropertyValue(SCALE_PROPERTY)).toBe("");
  });

  test("does not scale WQHD", () => {
    const viewport: Viewport = { width: "2560", height: "1440", devicePixelRatio: 1 };
    setViewport(viewport);

    scaleScreen(iFrame);

    expect(iFrame.style.getPropertyValue(SCALE_PROPERTY)).toBe("");
  });

  test("scales 4K UHD", () => {
    const viewport: Viewport = { width: "3840", height: "2160", devicePixelRatio: 1 };
    setViewport(viewport);

    scaleScreen(iFrame);

    expect(iFrame.style.getPropertyValue(SCALE_PROPERTY)).toBe("2");
  });

  test("scales 5K UHD", () => {
    const viewport: Viewport = { width: "5120", height: "2880", devicePixelRatio: 1 };
    setViewport(viewport);

    scaleScreen(iFrame);

    expect(iFrame.style.getPropertyValue(SCALE_PROPERTY)).toBe("3");
  });

  test("scales 8K UHD", () => {
    const viewport: Viewport = { width: "7680", height: "4320", devicePixelRatio: 1 };
    setViewport(viewport);

    scaleScreen(iFrame);

    expect(iFrame.style.getPropertyValue(SCALE_PROPERTY)).toBe("4");
  });

  // 21:9
  test("does not scale UWFHD", () => {
    const viewport: Viewport = { width: "2560", height: "1080", devicePixelRatio: 1 };
    setViewport(viewport);

    scaleScreen(iFrame);

    expect(iFrame.style.getPropertyValue(SCALE_PROPERTY)).toBe("");
  });

  test("does not scale UWQHD", () => {
    const viewport: Viewport = { width: "3440", height: "1440", devicePixelRatio: 1 };
    setViewport(viewport);

    scaleScreen(iFrame);

    expect(iFrame.style.getPropertyValue(SCALE_PROPERTY)).toBe("");
  });

  test("scales UWFHD", () => {
    const viewport: Viewport = { width: "5120", height: "2160", devicePixelRatio: 1 };
    setViewport(viewport);

    scaleScreen(iFrame);

    expect(iFrame.style.getPropertyValue(SCALE_PROPERTY)).toBe("2");
  });

  test("does not scale with low resolution on high DPI display", () => {
    const viewport: Viewport = { width: "540", height: "1170", devicePixelRatio: 2 };
    setViewport(viewport);

    scaleScreen(iFrame);

    expect(iFrame.style.getPropertyValue(SCALE_PROPERTY)).toBe("");
  });

  test("scales with high resolution on high DPI display", () => {
    const viewport: Viewport = { width: "1080", height: "2340", devicePixelRatio: 2 };
    setViewport(viewport);

    scaleScreen(iFrame);

    expect(iFrame.style.getPropertyValue(SCALE_PROPERTY)).toBe("2");
  });

  test("does not scale with low resolution on very high DPI display", () => {
    const viewport: Viewport = { width: "270", height: "585", devicePixelRatio: 3.5 };
    setViewport(viewport);

    scaleScreen(iFrame);

    expect(iFrame.style.getPropertyValue(SCALE_PROPERTY)).toBe("");
  });

  test("scales with high resolution on very high DPI display", () => {
    const viewport: Viewport = { width: "1080", height: "2340", devicePixelRatio: 3.5 };
    setViewport(viewport);

    scaleScreen(iFrame);

    expect(iFrame.style.getPropertyValue(SCALE_PROPERTY)).toBe("4");
  });

  test("does not scale with low resolution on ultra high DPI display", () => {
    const viewport: Viewport = { width: "270", height: "585", devicePixelRatio: 4 };
    setViewport(viewport);

    scaleScreen(iFrame);

    expect(iFrame.style.getPropertyValue(SCALE_PROPERTY)).toBe("");
  });

  test("scales with high resolution on ultra high DPI display", () => {
    const viewport: Viewport = { width: "1080", height: "2340", devicePixelRatio: 4 };
    setViewport(viewport);

    scaleScreen(iFrame);

    expect(iFrame.style.getPropertyValue(SCALE_PROPERTY)).toBe("4");
  });
});

In the test/assets/test directory, create a new file named ws-control-panel.test.ts and populate it with the below content:

ws-control-panel.test.ts
import "@testing-library/jest-dom";

import { fireEvent } from "@testing-library/dom";
import { initializeWSControlPanel } from "../ts/ws-control-panel";

describe("Workstation Control Panel", () => {
  let container: HTMLElement;
  let panel: HTMLElement;
  let chevron: HTMLElement;

  const collapsedClass = "-translate-y-full";
  const chevronFlippedClass = "rotate-180";
  const initialDisplayTimeMs = 2500;

  beforeEach(() => {
    jest.useFakeTimers();

    document.body.innerHTML = `
      <div id="ws-control-container">
        <div data-control-panel>
          <button data-control-handle>
            <svg data-control-chevron></svg>
          </button>
        </div>
      </div>
    `;

    container = document.getElementById("ws-control-container") as HTMLElement;
    panel = container.querySelector("[data-control-panel]") as HTMLElement;
    chevron = container.querySelector("[data-control-chevron]") as HTMLElement;
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  test("works on views without a control panel", () => {
    document.body.innerHTML = ``;

    expect(() => initializeWSControlPanel(document.body)).not.toThrow();
  });

  test("is expanded initially", () => {
    initializeWSControlPanel(container);

    expect(panel).not.toHaveClass(collapsedClass);
    expect(chevron).not.toHaveClass(chevronFlippedClass);
  });

  test("collapses after initial display time", () => {
    initializeWSControlPanel(container);

    jest.advanceTimersByTime(initialDisplayTimeMs);

    expect(panel).toHaveClass(collapsedClass);
    expect(chevron).toHaveClass(chevronFlippedClass);
  });

  test("collapses when chevron is clicked while expanded", () => {
    initializeWSControlPanel(container);

    fireEvent.click(chevron);

    expect(panel).toHaveClass(collapsedClass);
    expect(chevron).toHaveClass(chevronFlippedClass);
  });

  test("expands when chevron is clicked while collapsed", () => {
    initializeWSControlPanel(container);
    fireEvent.click(chevron);

    fireEvent.click(chevron);

    expect(panel).not.toHaveClass(collapsedClass);
    expect(chevron).not.toHaveClass(chevronFlippedClass);
  });

  test("expands when chevron is clicked after auto-collapse", () => {
    initializeWSControlPanel(container);
    jest.advanceTimersByTime(initialDisplayTimeMs);

    fireEvent.click(chevron);

    expect(panel).not.toHaveClass(collapsedClass);
    expect(chevron).not.toHaveClass(chevronFlippedClass);
  });
});

Update profile_test.exs to rename the launching_at assertions to locked_at, and add test cases for refresh_lock/2, get_orphaned_profiles/0 and subscribed?/1:

profile_test.exs
  test "refreshes lock for paid user" do
    user_id = Ecto.UUID.generate()
    {:ok, profile} = Profile.ensure_profile(user_id)
    last_lock_time = DateTime.add(DateTime.utc_now(), -150, :second)
    from(p in ProfileSchema, where: p.id == ^profile.id)
    |> Repo.update_all(set: [locked_at: last_lock_time])

    Profile.refresh_lock(profile, :new_user)
    profile = Repo.get_by!(ProfileSchema, user_id: user_id)

    assert DateTime.compare(profile.locked_at, last_lock_time) == :gt
  end

  test "considers deleted but not cleaned up profiles to be orphaned" do
    user_id = Ecto.UUID.generate()
    {:ok, profile} = Profile.ensure_profile(user_id)
    {:ok, profile} = Profile.mark_deleted(profile)

    assert Profile.get_orphaned_profiles() == [profile]
  end

Update workstation_test.exs (in test/deskterm) to add test cases for get_workstations/1 and delete_workstation/2, asserting that workstations are scoped to the owning profile:

workstation_test.exs
  test "returns empty list when profile is nil" do
    assert [] = Workstation.get_workstations(nil)
  end

  test "gets workstations belonging to profile" do
    profile = get_profile()

    :ok = Workstation.ensure_workstation(profile, get_workstation("Firefox"))
    :ok = Workstation.ensure_workstation(profile, get_workstation("Chrome"))

    assert ["Chrome", "Firefox"] =
             profile
             |> Workstation.get_workstations()
             |> Enum.map(& &1.ws_name)
  end

  test "does not get workstations belonging to other profiles" do
    profile = get_profile()
    other_profile = get_profile()

    :ok = Workstation.ensure_workstation(other_profile, get_workstation())

    assert [] = Workstation.get_workstations(profile)
  end

  test "deletes workstation belonging to profile" do
    profile = get_profile()
    :ok = Workstation.ensure_workstation(profile, get_workstation())

    assert :ok = Workstation.delete_workstation(profile, @ws_name)

    assert Repo.aggregate(WorkstationSchema, :count) == 0
  end

  test "does not delete workstations belonging to other profiles" do
    profile = get_profile()
    other_profile = get_profile()
    :ok = Workstation.ensure_workstation(profile, get_workstation())
    :ok = Workstation.ensure_workstation(other_profile, get_workstation())

    assert :ok = Workstation.delete_workstation(profile, @ws_name)

    assert Repo.get_by!(WorkstationSchema,
             profile_id: other_profile.id,
             ws_name: @ws_name
           )

    assert Repo.aggregate(WorkstationSchema, :count) == 1
  end

Update session_test.exs (in test/deskterm) to source sessions from TestSessionUtilities and add test cases for get_currently_used_hours/2 that verify only the portion of each session falling within the current subscription period is counted and that other profiles' sessions are excluded:

session_test.exs
  alias DesktermWeb.TestSessionUtilities, as: Utilities
  alias DesktermWeb.TestSessionUtilities.TimeSpan

  @acceptable_deviation 0.001

  test "profile with completed session partially within the subscription period has used hours counted only within current period" do
    profile = Utilities.create_profile()
    subscription_period_start = Utilities.get_time_hours_ago(15 * 24)

    Utilities.create_profile_session(
      profile.id,
      %TimeSpan{
        started_at: Utilities.get_time_hours_ago(16 * 24),
        ended_at: Utilities.get_time_hours_ago(14 * 24)
      }
    )

    currently_used_hours =
      Session.get_currently_used_hours(profile.id, subscription_period_start)

    assert_in_delta currently_used_hours, 24.0, @acceptable_deviation
  end

  test "excludes sessions from other profiles for used hours calculation" do
    profile = Utilities.create_profile()
    other_profile = Utilities.create_profile()
    subscription_period_start = Utilities.get_time_hours_ago(15 * 24)

    Utilities.create_profile_session(
      profile.id,
      %TimeSpan{
        started_at: Utilities.get_time_hours_ago(15 * 24),
        ended_at: Utilities.get_time_hours_ago(14 * 24)
      }
    )

    Utilities.create_profile_session(
      other_profile.id,
      %TimeSpan{
        started_at: Utilities.get_time_hours_ago(15 * 24),
        ended_at: Utilities.get_time_hours_ago(14 * 24)
      }
    )

    currently_used_hours =
      Session.get_currently_used_hours(profile.id, subscription_period_start)

    assert_in_delta currently_used_hours, 24.0, @acceptable_deviation
  end

Update session_cleanup_test.exs to source sessions from TestSessionUtilities, and add test cases that orphaned profiles are only marked as cleaned up once both their Fly app and VPN account are deleted:

session_cleanup_test.exs
  test "cleans up orphaned profile when Fly app and VPN account deletion succeed" do
    TestHideMeUtilities.when_delete_account_succeeds()
    TestFlyUtilities.when_delete_app_succeeds()
    profile = Utilities.create_orphaned_profile(with_resources: true)

    SessionCleanup.run()

    profile = Repo.get!(ProfileSchema, profile.id)
    assert profile.cleanup_completed_at != nil
  end

Update account_controller_test.exs to add test cases that deleting an account marks the profile as deleted, and only marks it as cleaned up when both the Fly app and VPN account deletions succeed:

account_controller_test.exs
  test "marks profile as cleaned up when Fly app and VPN account deletion succeed",
       %{conn: conn} do
    AuthenticationUtilities.when_get_client_succeeds()
    UserUtilities.when_get_user_succeeds()
    AccountAdminUtilities.when_get_admin_client_succeeds()
    AccountAdminUtilities.when_account_deletion_succeeds()
    TestHideMeUtilities.when_delete_account_succeeds()
    TestFlyUtilities.when_delete_app_succeeds()

    profile =
      TestSessionUtilities.create_profile(
        TestUserAtoms.user_id(),
        with_resources: true
      )

    conn = delete_account(conn, isAuthenticated: true)

    assert conn.status == 204
    profile = Repo.get!(ProfileSchema, profile.id)
    assert profile.deleted_at != nil
    assert profile.cleanup_completed_at != nil
  end

Update container_test.exs, new_user_test.exs, vpn_test.exs and workstation_test.exs (in test/deskterm_web/live/workstation) to add TestAppUtilities.when_lock_is_scheduled() (and when_message_is_cancelled() where the launch is aborted) now that the lock heartbeat is scheduled as soon as the profile is locked.

Update fly_app_test.exs to add when_lock_is_scheduled() to the old user launch tests, expecting it twice in the retry test since the heartbeat is scheduled on each launch attempt.

Production Code

Create the below database migration in the priv/repo/migrations directory to record when a paid subscription period started, used by the hours quota, and to index sessions for the hours calculation:

20260608120000_add_subscription_start_to_profiles.exs
defmodule Deskterm.Repo.Migrations.AddSubscriptionStartToProfiles do
  use Ecto.Migration

  def change do
    alter table(:profiles) do
      add :subscription_start, :utc_datetime
    end

    create index(:sessions, [:profile_id, :started_at])
  end
end

Create the below database migration in the priv/repo/migrations directory to track deferred cleanup of deleted profiles:

20260609120000_add_deletion_fields_to_profiles.exs
defmodule Deskterm.Repo.Migrations.AddDeletionFieldsToProfiles do
  use Ecto.Migration

  def change do
    alter table(:profiles) do
      add :deleted_at, :utc_datetime
      add :cleanup_completed_at, :utc_datetime
    end

    create index(:profiles, [:deleted_at],
             where: "cleanup_completed_at IS NULL",
             name: :profiles_pending_cleanup_index
           )
  end
end

Create the below database migration in the priv/repo/migrations directory to rename the launching_at column to the more general locked_at, since the profile lock is now also held while a running workstation is administered:

20260614120000_rename_launching_at_to_locked_at_in_profiles.exs
defmodule Deskterm.Repo.Migrations.RenameLaunchingAtToLockedAtInProfiles do
  use Ecto.Migration

  def change do
    rename table(:profiles), :launching_at, to: :locked_at
  end
end

Update profile.ex (schema) to rename launching_at to locked_at and add the subscription_start, deleted_at and cleanup_completed_at fields, casting all of them:

profile.ex
    field :locked_at, :utc_datetime
    field :subscription_start, :utc_datetime
    field :deleted_at, :utc_datetime
    field :cleanup_completed_at, :utc_datetime

Update profile.ex (in lib/deskterm) to add profile lookup, orphan querying, lock refresh, deletion marking and subscription helpers:

profile.ex
  def get_by_user_id(user_id) do
    Repo.get_by(Profile, user_id: user_id)
  end

  def get_orphaned_profiles do
    from(profile in Profile,
      where:
        not is_nil(profile.deleted_at) and is_nil(profile.cleanup_completed_at)
    )
    |> Repo.all()
  end

  def refresh_lock(_, :free_user), do: :ok

  def refresh_lock(profile, _) do
    from(profile_record in Profile, where: profile_record.id == ^profile.id)
    |> Repo.update_all(set: [locked_at: DateTime.utc_now()])
  end

  def mark_deleted(profile) do
    profile
    |> Profile.changeset(%{deleted_at: DateTime.utc_now()})
    |> Repo.update()
  end

  def mark_cleanup_completed(profile) do
    profile
    |> Profile.changeset(%{cleanup_completed_at: DateTime.utc_now()})
    |> Repo.update()
  end

  def subscribed?(%Deskterm.Schema.Profile{subscription_type: :none}), do: false
  def subscribed?(%Deskterm.Schema.Profile{}), do: true
  def subscribed?(_), do: false

The lock/2, refresh_lock/2 and unlock/2 functions all operate on the renamed locked_at column.

Update workstation.ex (in lib/deskterm) to list a profile's persisted workstations and delete one by name:

workstation.ex
  def get_workstations(nil), do: []

  def get_workstations(profile) do
    Repo.all(
      from workstation in Workstation,
        where: workstation.profile_id == ^profile.id,
        order_by: workstation.ws_name
    )
  end

  def delete_workstation(profile, ws_name) do
    Repo.delete_all(
      from workstation in Workstation,
        where:
          workstation.profile_id == ^profile.id and
            workstation.ws_name == ^ws_name
    )

    :ok
  end

Update session.ex (in lib/deskterm) to add get_currently_used_hours/2, which sums the duration of a profile's sessions that fall within the current subscription period:

session.ex
  def get_currently_used_hours(profile_id, current_period_start) do
    current_time = get_naive_current_time()
    current_start = get_naive_current_start(current_period_start)

    from(session in Session,
      where:
        session.profile_id == ^profile_id and
          (is_nil(session.ended_at) or session.ended_at >= ^current_start),
      select:
        coalesce(
          sum(
            fragment(
              "EXTRACT(EPOCH FROM (COALESCE(?, ?) - GREATEST(?, ?))) / 3600",
              session.ended_at,
              ^current_time,
              session.started_at,
              ^current_start
            )
          ),
          0.0
        )
    )
    |> Repo.one()
  end

  defp get_naive_current_time do
    DateTime.utc_now()
    |> DateTime.to_naive()
    |> NaiveDateTime.truncate(:second)
  end

  defp get_naive_current_start(current_period_start) do
    current_period_start
    |> DateTime.to_naive()
    |> NaiveDateTime.truncate(:second)
  end

In the lib/deskterm directory, create a new file named profile_cleanup.ex and populate it with the below content. It deletes the Fly app and VPN account associated with a profile and is shared by account deletion and the session cleanup task:

profile_cleanup.ex
defmodule Deskterm.ProfileCleanup do
  require Logger

  alias Deskterm.VPNAccount

  def delete_fly_app(nil), do: :ok

  def delete_fly_app(app_name) do
    case DesktermWeb.FlyAPI.delete_app(app_name) do
      :ok ->
        :ok

      _ ->
        Logger.error("Failed to delete Fly.io app: #{app_name}")
        :error
    end
  end

  def delete_vpn_account(nil), do: :ok

  def delete_vpn_account(vpn_account_id) do
    vpn_account = VPNAccount.get(vpn_account_id)

    if vpn_account && is_nil(vpn_account.deleted_at) do
      case DesktermWeb.HideMeAPI.delete_account(vpn_account.hideme_uuid) do
        :ok ->
          VPNAccount.delete(vpn_account)
          :ok

        _ ->
          Logger.error(
            "Failed to delete VPN account: #{vpn_account.hideme_uuid}"
          )

          :error
      end
    else
      :ok
    end
  end
end

Update session_cleanup.ex to delegate Fly app and VPN account deletion to Deskterm.ProfileCleanup, and to also clean up orphaned profiles (deleted accounts whose resources have not yet been removed), marking them as cleaned up only when both deletions succeed:

session_cleanup.ex
  def run do
    orphaned_sessions = Session.get_orphaned_sessions(DateTime.utc_now())
    Logger.info("There are #{length(orphaned_sessions)} orphaned sessions")
    Enum.each(orphaned_sessions, &clean_up_session/1)

    orphaned_profiles = Profile.get_orphaned_profiles()
    Logger.info("There are #{length(orphaned_profiles)} orphaned profiles")
    Enum.each(orphaned_profiles, &clean_up_profile/1)
  rescue
    _ ->
      Logger.critical("Session cleanup failed")
  end

  defp clean_up_profile(profile) do
    vpn_deletion_result =
      ProfileCleanup.delete_vpn_account(profile.vpn_account_id)

    fly_deletion_result = ProfileCleanup.delete_fly_app(profile.fly_app_name)

    if vpn_deletion_result == :ok && fly_deletion_result == :ok do
      Profile.mark_cleanup_completed(profile)

      Logger.info("Cleaned up orphaned resources for profile #{profile.id}")
    end
  rescue
    _ ->
      Logger.error("Failed to clean up orphaned profile #{profile.id}")
  end

Update vpn_locations.ex to rename the Illinois VPN location to "Chicago, IL" and re-order it alphabetically.

Update fly_api.ex to add a get_volume/2 callback, and fly.ex to implement it, fetching a volume's usage details from the Fly.io API:

fly_api.ex
  @callback get_volume(
              app_name :: String.t(),
              volume_id :: String.t()
            ) :: {:ok, map()} | :error

  def get_volume(app_name, volume_id) do
    impl().get_volume(app_name, volume_id)
  end
fly.ex
  @impl true
  def get_volume(app_name, volume_id) do
    case Req.get(
           Utilities.client(),
           url: "/apps/#{app_name}/volumes/#{volume_id}"
         ) do
      {:ok, %Req.Response{status: status, body: body}} when status == 200 ->
        {:ok, body}

      {:ok, %Req.Response{status: status, body: body}} ->
        Logger.error("""
        Failed to get Fly.io volume
          HTTP code: #{status}
          HTTP body: #{inspect(body)}
        """)

        :error

      _ ->
        Logger.error("Failed to get Fly.io volume")
        :error
    end
  end

Update hide_me.ex to treat a 404 response from the VPN account deletion endpoint as success, so cleanup is idempotent when an account is already gone:

hide_me.ex
      {:ok, %Req.Response{status: status}} when status in [202, 404] ->
        :ok

Update account_controller.ex to clean up a profile's Fly app and VPN account after the account is deleted, marking the profile as deleted up front and as cleaned up only when both deletions succeed:

account_controller.ex
  defp cleanup_profile(user_id) do
    case Profile.get_by_user_id(user_id) do
      nil ->
        :ok

      profile ->
        profile = mark_deleted(profile)

        fly_result = ProfileCleanup.delete_fly_app(profile.fly_app_name)
        vpn_result = ProfileCleanup.delete_vpn_account(profile.vpn_account_id)

        if fly_result == :ok and vpn_result == :ok do
          Profile.mark_cleanup_completed(profile)
        end

        :ok
    end
  rescue
    _ ->
      Logger.error("Failed to clean up profile after deletion")
      :ok
  end

  defp mark_deleted(profile) do
    case Profile.mark_deleted(profile) do
      {:ok, updated_profile} -> updated_profile
      _ -> profile
    end
  end

Update authentication.ex to reset the profile assign to nil on logout and account deletion.

Update launch_sequence.ex to add the workstation deletion sequence and expose it through get/1:

launch_sequence.ex
  @delete_workstation [
    :start_machine,
    :wait_for_machine,
    :wait_for_docker,
    :nuke_workstation,
    :stop_machine,
    :delete_from_db
  ]

  def get(:delete_workstation), do: @delete_workstation

Update workstation.ex (in lib/deskterm_web/live/workstation) to schedule the lock heartbeat as soon as the profile is locked, and to abort with the locked-profile message when locking fails:

workstation.ex
    with :ok <- Deskterm.Profile.lock(profile, user_type),
         [first_step | _] <- launch_sequence do
      socket = Utilities.schedule_lock_heartbeat(socket, user_type)
      send(self(), first_step)
      {:noreply, socket}
    else
      _ ->
        Logger.warning("Failed to lock profile")
        Utilities.abort_launch_locked(socket)
    end

Update workstation_utilities.ex to maintain a periodic lock heartbeat that keeps a paid user's profile locked while their workstation runs, to add the abort_launch_locked/1 flow, and to compute the hours and storage usage quotas:

workstation_utilities.ex
  @gb 1_073_741_824
  @lock_refresh_interval_ms 150_000

  def refresh_lock(socket) do
    profile = socket.assigns.profile
    user_type = FlyApp.get_user_type(profile)

    Deskterm.Profile.refresh_lock(profile, user_type)

    socket = schedule_lock_heartbeat(socket, user_type)
    {:noreply, socket}
  end

  def schedule_lock_heartbeat(socket, :free_user), do: socket

  def schedule_lock_heartbeat(socket, _user_type) do
    lock_timer =
      App.process().send_after(self(), :refresh_lock, @lock_refresh_interval_ms)

    assign(socket, lock_timer: lock_timer)
  end

  def get_quota_hours(:basic), do: 40
  def get_quota_hours(:pro), do: 80
  def get_quota_hours(:elite), do: 160

  def get_used_hours(%Profile{subscription_start: nil}), do: 0

  def get_used_hours(%Profile{id: id, subscription_start: period_start}) do
    id
    |> Deskterm.Session.get_currently_used_hours(period_start)
    |> floor()
  end

  def get_storage_usage(fly_app_name, fly_volume_id) do
    case DesktermWeb.FlyAPI.get_volume(fly_app_name, fly_volume_id) do
      {:ok, volume} -> get_volume_usage(volume)
      _ -> 0
    end
  end

The heartbeat timer is cancelled (and any in-flight :refresh_lock message drained) in cleanup/2, the lock_timer assign is added to reset_assigns/1, and abort_launch/1 no longer takes an unlock_profile argument now that abort_launch_locked/1 handles the already-locked case without unlocking.

In the lib/deskterm_web/live/workstation directory, create a new file named deletion.ex and populate it with the below content. It drives the workstation deletion flow, starting a Fly machine to remove the container and its image from the volume before deleting the workstation records:

deletion.ex
defmodule DesktermWeb.Workstation.Deletion do
  import Phoenix.Component
  import Phoenix.LiveView

  require Logger

  alias DesktermWeb.Analytics
  alias DesktermWeb.Container
  alias DesktermWeb.FlyApp
  alias DesktermWeb.Structs.Workstation.APICallDetails
  alias DesktermWeb.UILog
  alias DesktermWeb.Workstation.Deletion.Utilities, as: Utilities
  alias DesktermWeb.Workstation.LaunchSequence

  def start_deletion(socket) do
    profile = socket.assigns.profile
    user_type = FlyApp.get_user_type(profile)
    launch_sequence = LaunchSequence.get(:delete_workstation)

    case Deskterm.Profile.lock(profile, user_type) do
      :ok ->
        socket =
          socket
          |> assign(
            flow: :deletion,
            launch_sequence: launch_sequence,
            launch_ws_current_task: hd(launch_sequence),
            launch_ws_step: 0,
            view: "loading_workstation"
          )
          |> clear_flash()

        Analytics.track_view_change(socket, "deleting_workstation")

        send(self(), hd(launch_sequence))
        {:noreply, socket}

      :error ->
        socket =
          socket
          |> put_flash(:error, "Failed to delete workstation")
          |> push_event("show-toast", %{})

        {:noreply, socket}
    end
  end

  def advance_step(socket) do
    next_step = socket.assigns.launch_ws_step + 1
    next_task = Enum.at(socket.assigns.launch_sequence, next_step)

    case next_task do
      nil ->
        Utilities.complete_deletion(socket)

      _ ->
        send(self(), next_task)

        {:noreply,
         assign(socket,
           launch_ws_step: next_step,
           launch_ws_current_task: next_task
         )}
    end
  end

  def nuke_workstation(socket) do
    app_name = socket.assigns.fly_app_name
    command = Container.Utilities.get_remove_workstation_command(socket)

    case DesktermWeb.FlyAPI.execute_command_async(app_name, command) do
      {:ok, task} ->
        socket = UILog.log_command_on_ui(socket, command)
        {:noreply, assign(socket, active_task: task)}

      :error ->
        Logger.error("Failed to remove workstation")
        abort(socket)
    end
  end

  def stop_machine(socket) do
    app_name = socket.assigns.fly_app_name
    machine_id = socket.assigns.fly_machine_id

    socket =
      UILog.log_api_call_on_ui(socket, %APICallDetails{
        method: "POST",
        path: "/v1/apps/#{app_name}/machines/#{machine_id}/stop"
      })

    FlyApp.execute_api_call(socket, fn ->
      DesktermWeb.FlyAPI.stop_machine(app_name, machine_id)
    end)
  end

  def delete_from_db(socket) do
    profile = socket.assigns.profile
    ws_name = socket.assigns.ws_name_to_delete

    Deskterm.Workstation.delete_workstation(profile, ws_name)

    advance_step(socket)
  end

  def abort(socket) do
    profile = socket.assigns.profile
    app_name = socket.assigns.fly_app_name
    machine_id = socket.assigns.fly_machine_id

    if app_name && machine_id do
      DesktermWeb.TaskAPI.start(fn ->
        DesktermWeb.FlyAPI.stop_machine(app_name, machine_id)
      end)
    end

    Deskterm.Profile.unlock(profile, FlyApp.get_user_type(profile))

    socket =
      socket
      |> Utilities.reset_assigns()
      |> put_flash(:error, "Failed to delete workstation")
      |> push_event("show-toast", %{})

    Analytics.track_error(socket, "Workstation deletion failed")

    {:noreply, socket}
  end
end

In the lib/deskterm_web/live/workstation directory, create a new file named deletion_utilities.ex and populate it with the below content:

deletion_utilities.ex
defmodule DesktermWeb.Workstation.Deletion.Utilities do
  import Phoenix.Component
  import Phoenix.LiveView

  alias DesktermWeb.Analytics
  alias DesktermWeb.FlyApp

  def complete_deletion(socket) do
    profile = socket.assigns.profile
    ws_name = socket.assigns.ws_name_to_delete

    Deskterm.Profile.unlock(profile, FlyApp.get_user_type(profile))

    socket =
      socket
      |> reset_assigns()
      |> put_flash(:info, "Workstation deleted")
      |> push_event("show-toast", %{})

    Analytics.track_event(socket, "Workstation Deletion", %{image: ws_name})

    {:noreply, socket}
  end

  def reset_assigns(socket) do
    profile = socket.assigns.profile

    assign(socket,
      active_task: nil,
      console_command: nil,
      flow: :launch,
      launch_sequence: [],
      launch_ws_current_task: "",
      launch_ws_step: 0,
      overview_view: "workstations",
      user_workstations: Deskterm.Workstation.get_workstations(profile),
      view: "overview",
      ws_name_to_delete: nil
    )
  end
end

In the lib/deskterm_web/live/workstation directory, create a new file named delete_modal.ex and populate it with the below content. It builds the confirmation modal warning that deletion is irreversible:

delete_modal.ex
defmodule DesktermWeb.Workstation.DeletionModal do
  alias DesktermWeb.ModalAtoms
  alias DesktermWeb.ModalButtons
  alias DesktermWeb.ModalDescription

  @delete_ws_modal_event "confirm_delete_ws"

  def delete_ws_modal_event, do: @delete_ws_modal_event

  def get_delete_ws_modal(ws_name) do
    %DesktermWeb.Modal{
      description: %ModalDescription{
        title: "This action cannot be undone",
        text:
          "Deleting the #{ws_name} workstation will permanently and irreversibly delete the workstation and all associated data from persistent storage. This includes but is not limited to:",
        bullets: [
          "All files and settings in the workstation",
          "All installed applications in the workstation",
          "All data in the workstation"
        ]
      },
      buttons: %ModalButtons{
        positive_label: "Keep workstation",
        negative_label: "Delete workstation",
        positive_event: ModalAtoms.cancel_event(),
        negative_event: @delete_ws_modal_event
      }
    }
  end
end

Update container.ex to route a failed wait_for_docker to the deletion abort when running the deletion flow:

container.ex
      :error ->
        Logger.error("Failed to wait for Docker")

        case socket.assigns.flow do
          :deletion -> Deletion.abort(socket)
          _ -> WorkstationUtilities.abort_launch(socket)
        end

Update container_utilities.ex to build the command that removes a workstation's container and image from the machine:

container_utilities.ex
  def get_remove_workstation_command(socket) do
    machine_id = socket.assigns.fly_machine_id
    ws_name = socket.assigns.ws_name_to_delete
    image = Images.get_image(ws_name)

    %Command{
      caller: self(),
      machine_id: machine_id,
      command: [
        "sh",
        "-c",
        "docker rm -f #{get_container_name(ws_name)} 2>/dev/null; " <>
          "docker rmi #{image} 2>/dev/null; true"
      ]
    }
  end

Update session.ex (in lib/deskterm_web/live) to add shut_down_workstation/1, which ends the session, cleans up and returns the user to the overview, tracking the session duration:

session.ex
  def shut_down_workstation(socket) do
    track_session_end(socket)

    socket = WorkstationUtilities.cleanup(socket)

    socket =
      socket
      |> assign(
        view: "overview",
        overview_view: "browsers",
        console_command: nil
      )
      |> put_flash(:info, "Workstation shut down")

    Analytics.track_overview_view_change(socket, "browsers")

    {:noreply, Phoenix.LiveView.push_event(socket, "show-toast", %{})}
  end

Update app_live.ex to add the workstation administration assigns in mount/3 (flow, lock_timer, used_hours, used_storage_gb, user_workstations, ws_name_to_delete), to handle the delete_workstation, delete confirmation and shut_down_workstation events, to load the user's workstations and quotas when switching to the workstations and settings views, to add handle_info callbacks for :refresh_lock, :nuke_workstation, :stop_machine and :delete_from_db, and to route the shared task completion callbacks to the deletion flow based on the flow assign:

app_live.ex
  @impl true
  def handle_event("shut_down_workstation", _value, socket) do
    socket = Phoenix.LiveView.clear_flash(socket)

    Session.shut_down_workstation(socket)
  end

  @impl true
  def handle_info(:refresh_lock, socket) do
    WorkstationUtilities.refresh_lock(socket)
  end

  @impl true
  def handle_info({:api_call_complete, :ok, assigns}, socket) do
    socket = socket |> assign(active_task: nil) |> assign(assigns)

    case socket.assigns.flow do
      :deletion -> Deletion.advance_step(socket)
      _ -> WorkstationUtilities.advance_step(socket)
    end
  end

Update app_live.html.heex to forward the new assigns to the view components, passing flow and ws_name to the loading_workstation view and used_hours, used_storage_gb and user_workstations to the overview view:

app_live.html.heex
<%= if @view == "loading_workstation" do %>
  <DesktermWeb.Views.loading_workstation
    fly_app_name={@fly_app_name}
    flow={@flow}
    launch_ws_current_task={@launch_ws_current_task}
    launch_sequence={@launch_sequence}
    launch_ws_step={@launch_ws_step}
    session_expiration={@session_expiration}
    ws_auth_token={@ws_auth_token}
    ws_name={@ws_name}
  />
<% end %>
app_live.html.heex
    overview_view={@overview_view}
    profile={@profile}
    session={@session}
    used_hours={@used_hours}
    used_storage_gb={@used_storage_gb}
    user_workstations={@user_workstations}
  />
<% end %>

Update views.ex to add launch task labels for the deletion steps and a usage_percentage/2 helper used by the quota bars:

views.ex
  def launch_task_label(:nuke_workstation), do: "Nuking Workstation"
  def launch_task_label(:stop_machine), do: "Stopping Machine"

  def launch_task_label(:delete_from_db),
    do: "Deleting Workstation Records in Database"

  def usage_percentage(used, quota) when quota > 0 do
    (used / quota * 100)
    |> min(100)
    |> max(0)
    |> floor()
  end

  def usage_percentage(_used, _quota), do: 0

In the lib/deskterm_web/views/templates directory, create a new file named user_workstations.html.heex for the persistent workstations view. Via a cond it renders a login prompt when there is no session, a subscription prompt for free users, an empty state when the user has no persisted workstations, and otherwise a grid of the user's workstations, each with a remove button that pushes delete_workstation and a tile that pushes select_workstation:

user_workstations.html.heex
<div id="user-workstations">
  <%= cond do %>
    <% @session == nil -> %>
      <div class="
        flex
        flex-col
        items-center
        justify-center
        gap-3
        bg-gray-900/50
        rounded-2xl
        p-8
        border
        border-white/10
        text-center">
        <p class="text-md text-gray-300">
          To store your workstations in persistent storage, you need an account and a subscription
        </p>
        <button
          phx-click="login"
          type="button"
          class="
            relative
            flex
            items-center
            justify-center
            rounded-md
            px-4
            py-1.5
            mt-2
            text-sm/6
            font-semibold
            text-white
            bg-indigo-500
            hover:bg-indigo-400
            hover:scale-105
            transition-all
            focus-visible:ring-2
            focus-visible:ring-indigo-400
            cursor-pointer"
          disabled={@loading != nil}
        >
          <span class="text-sm/6 font-semibold">Login or Create Account</span>
        </button>
      </div>
    <% DesktermWeb.FlyApp.get_user_type(@profile) == :free_user -> %>
      <div class="
        flex
        flex-col
        items-center
        justify-center
        gap-3
        bg-gray-900/50
        rounded-2xl
        p-8
        border
        border-white/10
        text-center">
        <p class="text-md text-gray-300">
          To store your workstations in persistent storage, you need a subscription
        </p>
        <a
          href="/pricing"
          target="_blank"
          rel="noopener noreferrer"
          class="
            relative
            flex
            items-center
            justify-center
            rounded-md
            px-4
            py-1.5
            mt-2
            text-sm/6
            font-semibold
            text-white
            bg-indigo-500
            hover:bg-indigo-400
            hover:scale-105
            transition-all
            focus-visible:ring-2
            focus-visible:ring-indigo-400
            cursor-pointer"
        >
          <span class="text-sm/6 font-semibold">View Pricing</span>
        </a>
      </div>
    <% @user_workstations == [] -> %>
      <div class="
        flex
        flex-col
        items-center
        justify-center
        gap-2
        bg-gray-900/50
        rounded-2xl
        p-8
        border
        border-white/10
        text-center">
        <p class="text-white text-base font-semibold">
          You don't have any workstations in persistent storage
        </p>
      </div>
    <% true -> %>
      <ul class="
          grid
          grid-cols-3
          sm:grid-cols-5
          md:grid-cols-6
          xl:grid-cols-7
          gap-4
          bg-gray-900/50
          rounded-2xl
          p-4
          border
          border-white/10">
        <li
          :for={workstation <- @user_workstations}
          class="
            relative
            flex
            flex-col
            items-center
            gap-2
            rounded-2xl
            py-4
            px-4"
        >
          <button
            type="button"
            aria-label={"Delete #{workstation.ws_name}"}
            phx-click={
              JS.push("delete_workstation",
                value: %{ws_name: workstation.ws_name},
                page_loading: true
              )
            }
            class="
              absolute
              top-1
              right-1
              z-10
              flex
              items-center
              justify-center
              rounded-full
              p-1
              cursor-pointer
              transition-transform
              hover:scale-110"
          >
            <img src="/images/icons/remove.svg" alt="" class="size-4" />
          </button>
          <div
            class="
              flex
              flex-col
              items-center
              gap-2
              w-full
              cursor-pointer
              transition-transform
              duration-200
              hover:scale-110"
            phx-click={
              JS.push("select_workstation",
                value: %{ws_name: workstation.ws_name},
                page_loading: true
              )
            }
          >
            <img
              src={"/images/logos/workstations/#{workstation.ws_name |> String.split() |> hd()}.svg"}
              alt={workstation.ws_name}
              class="w-full"
            />
            <span class="text-white text-sm text-center">
              <%= for word <- String.split(workstation.ws_name) do %>
                {word}<br />
              <% end %>
            </span>
          </div>
        </li>
      </ul>
  <% end %>
</div>

Update loading_workstation.html.heex to show a "Deleting Workstation" heading and a "10 - 30 seconds" estimated deletion time when @flow == :deletion, add the workstation control panel (shown once the workstation is connected) as a collapsible top bar bound to the WSControlPanel hook with a Shut down button that pushes shut_down_workstation, and apply the integer screen scaling to the workstation iframe using the --ws-scale CSS variable:

loading_workstation.html.heex
<div
  id="loading-workstation"
  class="flex flex-col items-center justify-center min-h-dvh p-6"
  phx-hook="LoadingProgress"
  data-step-index={@launch_ws_step}
  data-total-steps={length(@launch_sequence)}
>
  <%= if @flow == :deletion do %>
    <h1 class="text-2xl font-semibold text-white mb-8">Deleting Workstation</h1>
  <% end %>
  <!-- Spinning Logo -->
  <div class="mb-10">
    <img
      src="/images/logo.png"
      alt="Deskterm"
      class="size-25 animate-spin"
    />
  </div>

<!-- Progress Text -->
  <p class="text-lg font-medium text-white mb-8">
    {DesktermWeb.Views.launch_task_label(@launch_ws_current_task)}
  </p>

<!-- Progress Bar -->
  <div class="w-full max-w-sm">
    <div class="relative">
      <div class="
          h-2
          bg-gray-800
          rounded-full
          border
          border-white/10
          overflow-hidden">
        <div
          data-progress-bar
          class="h-full rounded-full bg-green-400"
          style="width: 0%"
        />
      </div>
    </div>
    <p data-progress-label class="text-xs text-gray-400 text-center mt-3">
      0%
    </p>
  </div>

<!-- Estimated Time -->
  <%= if @flow == :deletion do %>
    <div class="mt-6 text-center">
      <p class="text-xs font-medium text-gray-400">Estimated Deletion Time</p>
      <span class="text-xs text-gray-400">10 - 30 seconds</span>
    </div>
  <% else %>
    <div class="mt-6 text-center">
      <p class="text-xs font-medium text-gray-400">Estimated Launch Time</p>
      <div class="
          inline-grid
          grid-cols-[auto_auto]
          gap-x-4
          gap-y-1
          text-xs
          text-gray-500">
        <span class="text-right">New workstation</span>
        <span class="text-left text-gray-400">1  3 minutes</span>
        <span class="text-right">Existing workstation</span>
        <span class="text-left text-gray-400">20 - 40 seconds</span>
      </div>
    </div>
  <% end %>

<!-- UI Log -->
  <div
    id="ui-log"
    phx-hook="UILog"
    class="
      w-full max-w-4xl mt-8
      bg-black/60
      backdrop-blur-sm
      border border-white/5
      rounded-xl
      overflow-hidden
      shadow-2xl
      shadow-green-400/5
    "
  >
    <div class="flex items-center px-4 py-2.5 bg-white/3 border-b border-white/5">
      <div class="flex items-center gap-1.5">
        <div class="size-1.5 rounded-full bg-green-400/60"></div>
        <span class="text-[10px] text-gray-500 font-mono tracking-widest uppercase">
          Log
        </span>
      </div>
    </div>
    <div
      id="log-container"
      phx-update="ignore"
      class="
        px-5 py-4
        h-96
        overflow-y-auto
        scrollbar-none
      "
    >
      <pre
        id="log-content"
        class="
          font-mono
          text-xs
          text-green-400
          leading-tight
          whitespace-pre-wrap
          break-all
          m-0"
      ></pre>
    </div>
  </div>

<!-- Invisible Form for Authenticating with Authentication Proxy -->
  <%= if @launch_ws_current_task == nil and @ws_auth_token do %>
    <form
      id="ws-auth-form"
      method="POST"
      action={"https://#{@fly_app_name}.fly.dev/authenticate"}
      target="ws-iframe"
      phx-hook="ConnectToWS"
      class="hidden"
    >
      <input type="hidden" name="token" value={@ws_auth_token} />
    </form>
  <% end %>

<!-- Session Countdown Timer -->
  <%= if @session_expiration do %>
    <div
      id="session-countdown"
      phx-hook="Countdown"
      data-expiration={DateTime.to_iso8601(@session_expiration)}
      class="
        fixed
        top-6
        left-1/2
        -translate-x-1/2
        z-60
        px-4
        py-2
        bg-gray-900/90
        backdrop-blur-sm
        border
        border-white/10
        rounded-lg
        shadow-lg"
    >
      <div class="flex items-center gap-2">
        <div class="size-2 rounded-full bg-green-400 animate-pulse"></div>
        <span class="text-sm font-mono text-white">
          <span data-countdown-label>05:00</span>
        </span>
      </div>
    </div>
  <% end %>
</div>

<!-- Workstation Control Panel -->
<%= if @launch_ws_current_task == nil and @ws_auth_token do %>
  <div
    id="ws-control-panel"
    phx-hook="WSControlPanel"
    class="
      fixed
      top-0
      inset-x-0
      z-70
      flex
      justify-center
      pointer-events-none"
  >
    <div
      data-control-panel
      class="
        relative
        w-full
        max-w-3xl
        transition-transform
        duration-500
        ease-in-out"
    >
      <div class="
          pointer-events-auto
          mx-3
          flex
          items-center
          justify-between
          gap-4
          rounded-b-2xl
          bg-gray-900/90
          backdrop-blur-md
          border
          border-t-0
          border-white/10
          shadow-2xl
          px-5
          py-3">
        <div class="flex items-center gap-2">
          <div class="size-2 rounded-full bg-green-400 animate-pulse"></div>
          <span class="text-sm font-medium text-white">{@ws_name}</span>
        </div>
        <div data-control-actions class="flex items-center gap-2">
          <button
            phx-click="shut_down_workstation"
            type="button"
            class="
              inline-flex
              items-center
              gap-1.5
              rounded-lg
              px-3
              py-1.5
              text-xs
              font-semibold
              text-red-300
              bg-red-500/10
              border
              border-red-500/30
              cursor-pointer
              hover:bg-red-500/20
              hover:text-red-200
              hover:scale-105
              transition"
          >
            <svg
              class="size-3.5"
              xmlns="http://www.w3.org/2000/svg"
              fill="none"
              viewBox="0 0 24 24"
              stroke-width="2"
              stroke="currentColor"
            >
              <path
                stroke-linecap="round"
                stroke-linejoin="round"
                d="M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9"
              />
            </svg>
            Shut down
          </button>
        </div>
      </div>
      <button
        data-control-handle
        type="button"
        aria-label="Toggle workstation control panel"
        class="
          pointer-events-auto
          absolute
          top-full
          left-1/2
          -translate-x-1/2
          flex
          items-center
          justify-center
          h-5
          w-12
          rounded-b-lg
          bg-gray-900/90
          backdrop-blur-md
          border
          border-t-0
          border-white/10
          text-gray-400
          hover:text-white
          cursor-pointer
          transition-colors"
      >
        <svg
          data-control-chevron
          class="size-3 transition-transform duration-500"
          xmlns="http://www.w3.org/2000/svg"
          fill="none"
          viewBox="0 0 24 24"
          stroke-width="2.5"
          stroke="currentColor"
        >
          <path
            stroke-linecap="round"
            stroke-linejoin="round"
            d="m4.5 15.75 7.5-7.5 7.5 7.5"
          />
        </svg>
      </button>
    </div>
  </div>
<% end %>

<!-- Workstation -->
<iframe
  id="ws-iframe"
  name="ws-iframe"
  class="
    invisible
    pointer-events-none
    fixed
    top-0
    left-0
    border-none
    z-50
    bg-transparent"
  style="
    width: calc(100vw / var(--ws-scale, 1));
    height: calc(100vh / var(--ws-scale, 1));
    transform: scale(var(--ws-scale, 1));
    transform-origin: top left;
    image-rendering: pixelated;"
  allowfullscreen
>
</iframe>

Update overview.html.heex to rename the "Sessions" tab to "Workstations" (switching its view to workstations), render the user_workstations component for that view, and pass the profile, used_hours and used_storage_gb assigns to the settings view:

overview.html.heex
          <%= if @overview_view == "workstations" do %>
            <DesktermWeb.Views.user_workstations
              loading={@loading}
              profile={@profile}
              session={@session}
              user_workstations={@user_workstations}
            />
          <% end %>

          <%= if @overview_view == "launch" do %>
            <DesktermWeb.Views.launch loading={@loading} profile={@profile} />
          <% end %>

          <%= if @overview_view == "settings" do %>
            <DesktermWeb.Views.settings
              loading={@loading}
              profile={@profile}
              session={@session}
              used_hours={@used_hours}
              used_storage_gb={@used_storage_gb}
            />
          <% end %>

Update settings.html.heex to add a "Usage" panel for subscribed users showing the time and storage quota bars, computed with DesktermWeb.Workstation.Utilities.get_quota_hours/1, get_volume_size/1 and DesktermWeb.Views.usage_percentage/2:

settings.html.heex
  <%= if Deskterm.Profile.subscribed?(@profile) do %>
    <!-- Usage -->
    <div class="rounded-2xl border border-white/10 bg-gray-900/50 p-4 md:col-span-2">
      <div class="flex items-center justify-center pb-4">
        <h3 class="text-md font-semibold text-white">Usage</h3>
      </div>
      <div class="space-y-4">
        <div>
          <div class="flex items-center justify-between pb-1">
            <span class="flex items-center gap-2 text-sm font-medium text-white">
              Time
            </span>
            <span class="text-sm text-gray-300">
              {@used_hours} of {DesktermWeb.Workstation.Utilities.get_quota_hours(
                @profile.subscription_type
              )} hours used
            </span>
          </div>
          <div class="h-2 w-full overflow-hidden rounded-full bg-white/10">
            <div
              class="h-full rounded-full bg-green-400"
              style={"width: #{DesktermWeb.Views.usage_percentage(@used_hours, DesktermWeb.Workstation.Utilities.get_quota_hours(@profile.subscription_type))}%"}
            >
            </div>
          </div>
        </div>
        <div>
          <div class="flex items-center justify-between pb-1">
            <span class="flex items-center gap-2 text-sm font-medium text-white">
              Storage
            </span>
            <span class="flex items-center text-sm text-gray-300">
              {@used_storage_gb} of {DesktermWeb.Workstation.Utilities.get_volume_size(
                @profile.subscription_type
              )} GB used
            </span>
          </div>
          <div class="h-2 w-full overflow-hidden rounded-full bg-white/10">
            <div
              class="h-full rounded-full bg-green-400"
              style={"width: #{DesktermWeb.Views.usage_percentage(@used_storage_gb || 0, DesktermWeb.Workstation.Utilities.get_volume_size(@profile.subscription_type))}%"}
            >
            </div>
          </div>
        </div>
      </div>
    </div>
  <% end %>

In the assets/ts/workstation directory, create a new file named scale-screen.ts and populate it with the below content:

scale-screen.ts
const LOWEST_ACCEPTABLE_WIDTH = 900;
const LOWEST_ACCEPTABLE_HEIGHT = 900;

export function scaleScreen(iFrame: HTMLIFrameElement): void {
  const DPR = window.devicePixelRatio ?? 1;
  const width = window.innerWidth * DPR;
  const height = window.innerHeight * DPR;

  const scaleX = width / LOWEST_ACCEPTABLE_WIDTH;
  const scaleY = height / LOWEST_ACCEPTABLE_HEIGHT;

  const scale = Math.floor(Math.min(scaleX, scaleY));

  if (scale > 1) iFrame.style.setProperty("--ws-scale", String(scale));
}

In the assets/ts directory, create a new file named ws-control-panel.ts and populate it with the below content. It wires up the collapsible workstation control panel and auto-collapses it after an initial display time:

ws-control-panel.ts
const INITIAL_DISPLAY_TIME_MS = 2500;
const COLLAPSED_CLASS = "-translate-y-full";
const CHEVRON_FLIPPED_CLASS = "rotate-180";

interface WSControlPanel {
  panel: HTMLElement;
  handle: HTMLElement;
  chevron: HTMLElement;
}

export function initializeWSControlPanel(
  container: HTMLElement
): number | undefined {
  const panel = container.querySelector<HTMLElement>("[data-control-panel]");
  const handle = container.querySelector<HTMLElement>("[data-control-handle]");
  const chevron = container.querySelector<HTMLElement>("[data-control-chevron]");

  if (!panel || !handle || !chevron) return undefined;

  const controlPanel: WSControlPanel = { panel, handle, chevron };

  handle.addEventListener("click", () => toggle(controlPanel));

  return window.setTimeout(
    () => collapse(controlPanel),
    INITIAL_DISPLAY_TIME_MS
  );
}

function toggle(controlPanel: WSControlPanel): void {
  if (controlPanel.panel.classList.contains(COLLAPSED_CLASS)) {
    expand(controlPanel);
  } else {
    collapse(controlPanel);
  }
}

function collapse(controlPanel: WSControlPanel): void {
  controlPanel.panel.classList.add(COLLAPSED_CLASS);
  controlPanel.chevron.classList.add(CHEVRON_FLIPPED_CLASS);
}

function expand(controlPanel: WSControlPanel): void {
  controlPanel.panel.classList.remove(COLLAPSED_CLASS);
  controlPanel.chevron.classList.remove(CHEVRON_FLIPPED_CLASS);
}

Move workstation-connection.ts into the assets/ts/workstation directory and update it to scale the workstation iframe by calling scaleScreen(iFrame) before submitting the form:

workstation-connection.ts
import { scaleScreen } from "./scale-screen";

export function connectToWorkstation(form: HTMLElement): void {
  const iFrame =
    document.getElementById("ws-iframe") as HTMLIFrameElement | null;

  if (!(form instanceof HTMLFormElement) || !iFrame) {
    return;
  }

  scaleScreen(iFrame);

  // ...existing connection handling...
}

Update live-view.ts to import connectToWorkstation from its new location and register the WSControlPanel hook, which initialises the control panel on mount and clears its collapse timer on destroy:

live-view.ts
import { initializeWSControlPanel } from "./ws-control-panel";

function getWSControlPanelHook() {
  let collapseTimer: number | undefined;

  return {
    mounted(this: ViewHookInterface) {
      collapseTimer = initializeWSControlPanel(this.el);
    },
    destroyed() {
      clearTimeout(collapseTimer);
    }
  };
}

Add the workstation remove icon at priv/static/images/icons/remove.svg, used by the delete buttons in the persistent workstations view.

Update footer.html.heex to correct the LinkedIn profile URL:

footer.html.heex
        <a
          href="https://www.linkedin.com/in/henrik-br%C3%BCsecke-07961791/"
          target="_blank"
          rel="noopener noreferrer"
          class="text-gray-400 hover:text-gray-300"
        >