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:
- 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:
Automated Tests
In the test/support directory, create a new file named session_utilities.ex and populate it with the below content:
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:
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:
@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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
@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
@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:
{: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:
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:
@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:
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:
@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:
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:
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:
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:
: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:
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:
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:
@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:
<%= 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 %>
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:
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:
<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:
<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:
<%= 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:
<%= 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:
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:
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:
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:
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:
<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"
>


