Skip to content

Launch Workstation

The launch workstation view allows the user to perform the below tasks before launching their workstation:

  • Choose a workstation location
  • Choose a VPN location
  • Consent to the legal terms
    • Terms of Service
    • Acceptable Use Policy
    • Privacy Policy
    • Cookie Policy

The launch workstation view is displayed after a user selects a workstation to start.

Architecture

To enable the user to make a more informed decision which workstation location to choose, we will ping each location to measure the latency. On the UI, an Automatic option will be offered that automatically selects the location with the lowest latency.

Similarly an automatic option will be offered to users for the VPN location. Once an automatic workstation location has been determined, the automatic VPN location will be updated to be close to the automatic workstation location.

Ping Algorithm

To get more accurate latency results, we will ping each location 4 times in total. The first time is a warm-up ping to establish the TLS connection. It is not considered for the latency measurement. A TLS handshake requires several messages back and forth, therefore it should not be considered for latency measurements.

Once the warm-up ping is completed 3 more pings are performed. Out of those 3 the lowest value is used and presented to the user. I first considered displaying the mean or median of those pings to the user, however outliers due to a flaky WiFi connection for example can lead to misleading results.

Note that the pings performed are traditional HTTP requests to a light web page. They are not the same kind of pings you would perform from your terminal for example. The reason for this is that it is not possible from a user's web browser to perform a traditional ping.

Pings are performed from the user's web browser. Special care must be taken when using SSR frameworks (such as the Phoenix framework that we are using) that the ping is performed from the user's web browser and not from the web server hosting the SSR web app. Therefore most of the ping logic will be implemented in TypeScript and not Elixir.

Ping Fleet

To allow location pings in a cost-effective manner we will use Fly.io to host our ping fleet. In each workstation location, we will have a Fly micro VM running an nginx server hosting a lightweight web page. Each micro VM will have minimal hardware resources as for the purposes of responding to pings not much hardware power is required. Each micro VM will have a quarter of a shared CPU and 256MB RAM. When the machine has not been pinged in a while, it will automatically suspend to keep operating costs to a minimum. Fly micro VMs can be resumed with minimal delay (usually in the ms range) when necessary.

We will have separate ping fleets for the test and production environments. The production ping fleet will get its own subdomain: ping.deskterm.com. The test environment ping fleet will use the automatically generated domain provided by Fly.io: deskterm-ping-test.fly.dev/.

Configuration

Fly.io Token

To setup the ping fleet we will need an account on Fly.io. Start by creating an account on Fly.io. Once you have an account, navigate to your dashboard and select Tokens on the left sidebar. Then click Create Organization Token:

Fly Token 1

Copy the generated token and store it in a secure place, e.g. your password manager.

Fly Token 2

You should now have a Fly.io organization token:

Fly Token 3

Now we will add this token as a CI variable to GitLab. Create a new variable with the Key set to FLY_API_TOKEN and the Value set to the token you just generated. Only copy everything after the space from the token into the GitLab CI variable, i.e. exclude the FlyV1 prefix. Remember to enable the checkbox Masked and hidden for the Visibility section. When you are done click Add variable. For more information about creating GitLab CI variables, see the CI Variables section of the Technical Documentation page from the Epic Fantasy Forge Development Guide.

Fly Token 4

App Setup

To setup our apps on Fly.io, we will use the Fly.io CLI tool. To install this tool, run the below command:

curl -L https://fly.io/install.sh | sh

Once the tool is installed, run the below command to create the test ping app:

fly apps create deskterm-ping-test

Similarly, create a separate Fly.io app for the production ping fleet:

fly apps create deskterm-ping-production

Custom Domain

For the production ping fleet, we will use the subdomain ping.deskterm.com. To use this subdomain, we first need to add IP addresses to our production ping app in Fly.io. Accept all the prompts from the below command:

fly ips allocate -a deskterm-ping-production

Next add a TLS certificate for your custom domain in Fly.io:

fly certs add ping.deskterm.com -a deskterm-ping-production

The output of the above command should include instructions for how to configure your DNS:

Ping Custom Domain 1

Go ahead to your DNS provider, e.g. [Cloudflare], and configure the above mentioned A and AAAA records. Note, in this case turn off the Proxy status toggle switch. We don't want our ping domain to be proxied through Cloudflare. That would only add additional latency. For more information on how to configure custom domains, please see the Custom Domain section on the User Facing Documentation page of the Epic Fantasy Forge Development Guide.

Ping Custom Domain 2

Ping Custom Domain 3

You can check the status of your TLS certificate by running the below command:

fly certs check ping.deskterm.com -a deskterm-ping-production

Eventually the status should become Issued:

Ping Custom Domain 4

Due to delays with propagating DNS updates, it may take some until the TLS certificate is issued. However usually the TLS certificate is issued within minutes or hours.

Tests

E2E Test

Before implementing the launch workstation view we will create an E2E test in Qase. Add a new test case to the Common suite named Launch Workstation:

Launch Workstation Test 1

Launch Workstation Test 2

Launch Workstation Test 3

Launch Workstation Test 4

Automated Tests

In the assets/test directory of your Phoenix project, create a new file named latency-measurement.test.ts and populate it with the below content:

latency-measurement.test.ts
import "@testing-library/jest-dom";

import {
  AutomaticBoxes,
  frankfurtID,
  frankfurtLatency,
  initializeAutomaticBoxes,
  initializeHTML,
  initializeLocationBoxes,
  LocationBoxes,
  stockholmID,
  stockholmLatency
} from "./utilities/latency-test-utils";
import { determineLatencies } from "../ts/workstations/latency-measurement";
import { pingLocations } from "../ts/workstations/ping";

jest.mock("../ts/workstations/ping");

describe("Latency Measurement", () => {
  const errorSVG = 'img[src="/images/icons/error.svg"]';
  const hidden = "hidden";
  const mockPingLocations =
    pingLocations as jest.MockedFunction<typeof pingLocations>;

  let automaticBoxes: AutomaticBoxes;
  let locationBoxes: LocationBoxes;

  beforeEach(() => {
    initializeHTML();

    automaticBoxes = initializeAutomaticBoxes();
    locationBoxes = initializeLocationBoxes();
    mockPingLocations.mockClear();
  });

  test("shows error when ping fails", () => {
    wheneverPingFails();

    determineLatencies(document.body);

    expect(locationBoxes.stockholm.loadingSpinner).toHaveClass(hidden);
    expect(locationBoxes.stockholm.latencyText).not.toHaveClass(hidden);
    expect(locationBoxes.stockholm.latencyText.querySelector(errorSVG))
      .not.toBeNull();
    expect(locationBoxes.frankfurt.loadingSpinner).toHaveClass(hidden);
    expect(locationBoxes.frankfurt.latencyText).not.toHaveClass(hidden);
    expect(locationBoxes.frankfurt.latencyText.querySelector(errorSVG))
      .not.toBeNull();
    expect(automaticBoxes.wsAutomaticLoadingSpinner).toHaveClass(hidden);
    expect(automaticBoxes.wsAutomaticLatencyText).not.toHaveClass(hidden);
    expect(automaticBoxes.wsAutomaticLatencyText.querySelector(errorSVG))
      .not.toBeNull();
    expect(automaticBoxes.vpnAutomaticSpinner.querySelector(errorSVG))
      .not.toBeNull();
    expect(automaticBoxes.vpnAutomaticText.textContent).toBe('Error');
  });

  test("shows latencies when ping succeeds", () => {
    wheneverPingSucceeds();

    determineLatencies(document.body);

    expect(automaticBoxes.wsAutomaticLoadingSpinner).toHaveClass(hidden);
    expect(automaticBoxes.wsAutomaticLatencyText).not.toHaveClass(hidden);
    expect(automaticBoxes.wsAutomaticLatencyText.textContent)
      .toMatch(`${stockholmLatency} ms`);
    expect(locationBoxes.stockholm.loadingSpinner).toHaveClass(hidden);
    expect(locationBoxes.stockholm.latencyText).not.toHaveClass(hidden);
    expect(locationBoxes.stockholm.latencyText.textContent)
      .toMatch(`${stockholmLatency} ms`);
    expect(locationBoxes.frankfurt.loadingSpinner).toHaveClass(hidden);
    expect(locationBoxes.frankfurt.latencyText).not.toHaveClass(hidden);
    expect(locationBoxes.frankfurt.latencyText.textContent)
      .toMatch(`${frankfurtLatency} ms`);
    expect(automaticBoxes.vpnAutomaticSpinner).toHaveClass(hidden);
    expect(automaticBoxes.vpnAutomaticText.textContent).toBe('Stockholm');
  });

  function wheneverPingFails(): void {
    mockPingLocations.mockImplementation((_ids, callbacks) => {
      callbacks.onError({ locationID: stockholmID });
      callbacks.onError({ locationID: frankfurtID });
    });
  }

  function wheneverPingSucceeds(): void {
    mockPingLocations.mockImplementation((_ids, callbacks) => {
      callbacks.onResult({
        locationID: stockholmID,
        latency: stockholmLatency
      });
      callbacks.onResult({
        locationID: frankfurtID,
        latency: frankfurtLatency
      });
    });
  }
});

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

location-selector.test.ts
import "@testing-library/jest-dom";

import {
  Buttons,
  CountrySections,
  initializeButtons,
  intializeCountrySections,
  intializeSections,
  Sections
} from "./utilities/location-test-utils";
import { fireEvent } from "@testing-library/dom";
import { initializeHTML } from "./utilities/location-html";
import { initializeVPNLocation } from "../ts/workstations/location-selector";

describe("Location Selector", () => {
  const hidden = "hidden";

  let countrySections = {} as CountrySections;
  let sections = {} as Sections;
  let buttons = {} as Buttons;

  let selectedLocation: HTMLInputElement;

  beforeEach(() => {
    initializeHTML();

    countrySections = intializeCountrySections();
    sections = intializeSections(countrySections);
    buttons = initializeButtons();

    selectedLocation =
      document.querySelector("#vpn-location-input") as HTMLInputElement;

    initializeVPNLocation(document.body);
  });

  test("works on pages without location selector", () => {
    document.body.innerHTML = ``;

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

  test("show regions initially", () => {
    expect(sections.regions).not.toHaveClass(hidden);
    expect(sections.countries.countries).toHaveClass(hidden);
    expect(sections.locations).toHaveClass(hidden);
  });

  test("continues to show regions when user clicks non-region button", () => {
    fireEvent.click(sections.regions);
    expect(sections.regions).not.toHaveClass(hidden);
    expect(sections.countries.countries).toHaveClass(hidden);
    expect(sections.locations).toHaveClass(hidden);
  });

  test("shows EMEA countries when user selects EMEA region", () => {
    fireEvent.click(buttons.EMEA);

    expect(sections.countries.countries).not.toHaveClass(hidden);
    expect(sections.countries.EMEACountries).not.toHaveClass(hidden);
    expect(sections.countries.AMERCountries).toHaveClass(hidden);
    expect(sections.regions).toHaveClass(hidden);
    expect(sections.locations).toHaveClass(hidden);
  });

  test("shows Swedish locations when user selects Sweden", () => {
    fireEvent.click(buttons.EMEA);
    fireEvent.click(buttons.sweden);

    expect(sections.locations).not.toHaveClass(hidden);
    expect(sections.swedishLocations).not.toHaveClass(hidden);
    expect(sections.countries.countries).toHaveClass(hidden);
    expect(sections.regions).toHaveClass(hidden);
  });

  test("shows regions when user navigates back from country view", () => {
    fireEvent.click(buttons.EMEA);
    fireEvent.click(buttons.backButton);

    expect(sections.regions).not.toHaveClass(hidden);
    expect(sections.countries.countries).toHaveClass(hidden);
    expect(sections.locations).toHaveClass(hidden);
  });

  test("shows countries when user navigates back from locations view", () => {
    fireEvent.click(buttons.EMEA);
    fireEvent.click(buttons.sweden);
    fireEvent.click(buttons.backButton);

    expect(sections.countries.countries).not.toHaveClass(hidden);
    expect(sections.locations).toHaveClass(hidden);
    expect(sections.regions).toHaveClass(hidden);
  });

  test("sets input value when user selects location", () => {
    fireEvent.click(buttons.EMEA);
    fireEvent.click(buttons.sweden);
    fireEvent.click(buttons.stockholm);

    expect(selectedLocation.value).toBe("se-stockholm");
  });
});

In the assets/test directory, create a new directory named utilities and move the file animation-test-utils.ts from assets/test into this new directory. Naturally some import paths may need to be adjusted as a result of this file move. The IDE may offer to do this for you.

Furthermore create a new file named location-html.ts in the new utilities directory:

location-html.ts
export function initializeHTML() {
  document.body.innerHTML = `
    <div id="vpn-location-selector">
      <input id="vpn-location-input"/>
      ${regionView()}
      ${countryView()}
      ${locationView()}
    </div>
  `;
}

function regionView() {
  return `
    <div id="vpn-region-view" data-view>
      <button
        data-action="narrow"
        data-target-view="1"
        data-target-list="vpn-country-amer"
      >
      </button>
      <button
        data-action="narrow"
        data-target-view="1"
        data-target-list="vpn-country-emea"
      >
      </button>
    </div>
  `;
}

function countryView() {
  return `
    <div
      id="vpn-country-view"
      data-view
      data-list-group="vpn-country-list"
      class="hidden"
    >
      <button data-action="back"></button>
      <div id="vpn-country-amer" class="vpn-country-list">
        <button
          data-action="narrow"
          data-target-view="2"
          data-target-list="vpn-location-ca"
        >
        </button>
        <button
          data-action="narrow"
          data-target-view="2"
          data-target-list="vpn-location-us"
        >
        </button>
      </div>
      <div id="vpn-country-emea" class="vpn-country-list hidden">
        <button
          data-action="narrow"
          data-target-view="2"
          data-target-list="vpn-location-de"
        >
        </button>
        <button
          data-action="narrow"
          data-target-view="2"
          data-target-list="vpn-location-se"
        >
        </button>
      </div>
    </div>
  `;
}

function locationView() {
  return `
    <div
      id="vpn-location-view"
      data-view
      data-list-group="vpn-location-list"
      class="hidden"
    >
      <button data-action="back"></button>
      <div id="vpn-location-ca" class="vpn-location-list">
        <button data-action="select" data-value="ca-toronto"></button>
      </div>
      <div id="vpn-location-us" class="vpn-location-list">
        <button data-action="select" data-value="us-atlanta"></button>
        <button data-action="select" data-value="us-chicago"></button>
      </div>
      <div id="vpn-location-de" class="vpn-location-list">
        <button data-action="select" data-value="de-frankfurt"></button>
      </div>
      <div id="vpn-location-se" class="vpn-location-list">
        <button data-action="select" data-value="se-stockholm"></button>
        <button data-action="select" data-value="se-gothenburg"></button>
      </div>
    </div>
    `;
}

In the assets/test/utilities directory, create a new file named latency-test-utils.ts and populate it with the below content:

latency-test-utils.ts
export interface AutomaticBoxes {
  wsAutomaticLoadingSpinner: HTMLElement;
  wsAutomaticLatencyText: HTMLElement;
  vpnAutomaticSpinner: HTMLElement;
  vpnAutomaticText: HTMLElement;
}

export interface LocationBoxes {
  stockholm: {
    loadingSpinner: HTMLElement;
    latencyText: HTMLElement;
  };
  frankfurt: {
    loadingSpinner: HTMLElement;
    latencyText: HTMLElement;
  };
}

export const stockholmID = 'arn';
export const stockholmLatency = 25;

export const frankfurtID = 'fra';
export const frankfurtLatency = 50;

export function initializeLocationBoxes(): LocationBoxes {
  const stockholmLoadingSpinner =
    document.querySelector(
      `[data-latency-id="${stockholmID}"] .ws-latency-spinner`
    ) as HTMLElement;
  const stockholmLatencyText =
    document.querySelector(
      `[data-latency-id="${stockholmID}"] .ws-latency-text`
    ) as HTMLElement;
  const frankfurtLoadingSpinner =
    document.querySelector(
      `[data-latency-id="${frankfurtID}"] .ws-latency-spinner`
    ) as HTMLElement;
  const frankfurtLatencyText =
    document.querySelector(
      `[data-latency-id="${frankfurtID}"] .ws-latency-text`
    ) as HTMLElement;

  return {
    stockholm: {
      loadingSpinner: stockholmLoadingSpinner,
      latencyText: stockholmLatencyText,
    },
    frankfurt: {
      loadingSpinner: frankfurtLoadingSpinner,
      latencyText: frankfurtLatencyText,
    },
  };
}

export function initializeAutomaticBoxes(): AutomaticBoxes {
  const wsAutomaticLoadingSpinner = document.querySelector(
    '[data-latency-id="automatic"] .ws-latency-spinner'
  ) as HTMLElement;
  const wsAutomaticLatencyText = document.querySelector(
    '[data-latency-id="automatic"] .ws-latency-text'
  ) as HTMLElement;
  const vpnAutomaticSpinner = document.querySelector(
    '#vpn-location-selector button[data-value="automatic"] #vpn-auto-spinner'
  ) as HTMLElement;
  const vpnAutomaticText = document.querySelector(
    '#vpn-location-selector button[data-value="automatic"] .text-xs'
  ) as HTMLElement;

  return {
    wsAutomaticLoadingSpinner,
    wsAutomaticLatencyText,
    vpnAutomaticSpinner,
    vpnAutomaticText,
  };
}

export function initializeHTML(): void {
  document.body.innerHTML = `
    ${WSAutomaticView()}
    ${WSLocationsView()}
    ${VPNView()}
  `;
}

function WSAutomaticView(): string {
  return `
    <button data-value="automatic" data-action="select">
      <span class="flex items-center gap-2">
        <span class="country-flag-emoji text-2xl">🌐</span>
        <span class="text-xs text-gray-300">TBD</span>
      </span>
      <span data-latency-id="automatic">
        <span class="ws-latency-spinner"></span>
        <span class="ws-latency-text hidden"></span>
      </span>
    </button>
  `;
}

function WSLocationsView(): string {
  return `
    <button data-value="${stockholmID}" data-action="select">
      <span class="flex items-center gap-2">
        <span class="country-flag-emoji text-2xl">🇸🇪</span>
        <span class="text-xs text-gray-300">Stockholm</span>
      </span>
      <span data-latency-id="${stockholmID}">
        <span class="ws-latency-spinner"></span>
        <span class="ws-latency-text hidden"></span>
      </span>
    </button>
    <button data-value="${frankfurtID}" data-action="select">
      <span class="flex items-center gap-2">
        <span class="country-flag-emoji text-2xl">🇩🇪</span>
        <span class="text-xs text-gray-300">Frankfurt</span>
      </span>
      <span data-latency-id="${frankfurtID}">
        <span class="ws-latency-spinner"></span>
        <span class="ws-latency-text hidden"></span>
      </span>
    </button>
  `;
}

function VPNView(): string {
  return `
    <div id="vpn-location-selector">
      <button data-action="select" data-value="automatic">
        <span class="flex items-center gap-2">
          <span class="country-flag-emoji text-2xl">🌐</span>
          <span class="text-xs text-gray-300">TBD</span>
        </span>
        <span id="vpn-auto-spinner">Loading...</span>
      </button>
      <button data-action="select" data-value="se-stockholm">
        <span class="country-flag-emoji">🇸🇪</span>
        <span>Stockholm</span>
      </button>
      <button data-action="select" data-value="de-frankfurt">
        <span class="country-flag-emoji">🇩🇪</span>
        <span>Frankfurt</span>
      </button>
    </div>
  `;
}

In the assets/test/utilities directory, create a new file named location-test-utils.ts and populate it with the below content:

location-test-utils.ts
export interface CountrySections {
  AMERCountries: HTMLDivElement;
  EMEACountries: HTMLDivElement;
  countries: HTMLDivElement;
}

export interface Sections {
  regions: HTMLDivElement;
  countries: CountrySections;
  locations: HTMLDivElement;
  swedishLocations: HTMLDivElement;
}

export interface Buttons {
  backButton: HTMLButtonElement;
  EMEA: HTMLButtonElement;
  sweden: HTMLButtonElement;
  stockholm: HTMLButtonElement;
}

export function intializeCountrySections(): CountrySections {
  return {
    AMERCountries:
      document.querySelector("#vpn-country-amer") as HTMLDivElement,
    EMEACountries:
      document.querySelector("#vpn-country-emea") as HTMLDivElement,
    countries: document.querySelector("#vpn-country-view") as HTMLDivElement,
  };
}

export function intializeSections(countrySections: CountrySections): Sections {
  return {
    regions: document.querySelector("#vpn-region-view") as HTMLDivElement,
    countries: countrySections,
    locations: document.querySelector("#vpn-location-view") as HTMLDivElement,
    swedishLocations:
      document.querySelector("#vpn-location-se") as HTMLDivElement,
  };
}

export function initializeButtons(): Buttons {
  return {
    backButton:
      document.querySelector(
        "#vpn-country-view button[data-action='back']"
      ) as HTMLButtonElement,
    EMEA: document.querySelector(
      "#vpn-region-view button[data-target-list='vpn-country-emea']"
    ) as HTMLButtonElement,
    sweden: document.querySelector(
      "#vpn-country-emea button[data-target-list='vpn-location-se']"
    ) as HTMLButtonElement,
    stockholm: document.querySelector(
      "#vpn-location-se button[data-value='se-stockholm']"
    ) as HTMLButtonElement,
  };
}

In the directory test directory of your Phoenix project, create a new file named workstations_test.exs and populate it with the below content:

workstations_test.exs
defmodule DesktermWeb.WorkstationsTest do
  use DesktermWeb.ConnCase, async: true
  import Phoenix.LiveViewTest

  alias DesktermWeb.TestAppUtilities

  test "shows launch view when user selects workstation", %{
    conn: conn
  } do
    view = TestAppUtilities.when_is_unauthenticated(conn)

    render_click(view, "select_workstation", %{workstation: "chrome"})

    assert has_element?(view, "#launch")
    refute has_element?(view, "#workstations")
  end
end

Modify oauth_test.exs to check for the new loading spinner (with new id):

oauth_test.exs
assert has_element?(
             view,
             "#loading-spinner-oauth_#{TestOAuthAtoms.provider()}"
           )

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

app_atoms.ex
defmodule DesktermWeb.TestAppAtoms do
  def path, do: "/app"
end

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

app_utilities.ex
defmodule DesktermWeb.TestAppUtilities do
  use DesktermWeb.ConnCase
  import Phoenix.LiveViewTest

  alias DesktermWeb.TestAppAtoms

  def when_is_unauthenticated(conn) do
    {:ok, view, _html} = live(conn, TestAppAtoms.path())
    render_click(view, "continue_without_account")
    render_click(view, "confirm_without_account")

    view
  end
end

Production Code

To prevent the scrollbar showing, append the below to app.css:

app.css
.scrollbar-none {
  -ms-overflow-style: none;
  scrollbar-width: none;
}
.scrollbar-none::-webkit-scrollbar {
  display: none;
}

Additionally add the below line near the top of the file. Without this line the Tailwind CSS classes appearing only in TypeScript files (and not in the HTML templates), will not work.

app.css
@source "../ts";

For better code organization, we will move the animation related files into a dedicated animations directory. In the assets/ts directory of your Phoenix project, create a new directory named animations. Move the below two files into this new directory. They are currently located in the assets/ts directory.

  • animation-utilities.ts
  • animations.ts

Naturally the moving of these files requires us to update some import paths. Your IDE may offer to automatically update the imports as you move these files.

In live-view.ts add code for the new WSLocation and VPNLocation Phoenix hooks that we will add. Modify the initializeLiveView function to also use these new hooks:

live-view.ts
hooks.VPNLocation = getVPNLocationHook();
hooks.WSLocation = getWSLocationHook();

Now add these two new functions to the bottom of the file:

live-view.ts
function getWSLocationHook() {
  return {
    mounted(this: ViewHookInterface) {
      initializeWSLocation(this.el);
    }
  };
}

function getVPNLocationHook() {
  return {
    mounted(this: ViewHookInterface) {
      initializeVPNLocation(this.el);
    }
  };
}

In the assets/ts directory, create a new directory named workstations. Inside this new directory create a new file named constants.ts and populate it with the below content:

constants.ts
export const ERROR_SVG =
  '<img src="/images/icons/error.svg" class="inline size-6" alt="error" />';
export const HIDDEN = 'hidden';
export const SELECTED_CLASSES = ['selected', 'ring-2', 'ring-blue-500'];

In the assets/ts/workstations directory, create a new file named latency-measurement.ts and populate it with the below content:

latency-measurement.ts
import {
  getLatencyColor,
  setWSAutoTextAndFlag,
  updateButtonLabel,
  updateVPNAutoErrorDisplay,
  updateVPNAutoLocation,
} from './latency-utilities';
import { ERROR_SVG, HIDDEN } from './constants';
import {
  LatencyError,
  LatencyResult,
  pingLocations,
  PingResultCallbacks
} from './ping';

export function determineLatencies(container: HTMLElement): void {
  const latencyElements =
    container.querySelectorAll<HTMLElement>('[data-latency-id]');
  const locationIDs: string[] = [];

  latencyElements.forEach((latencyElement) => {
    const locationID = latencyElement.dataset.latencyId as string;

    if (locationID !== 'automatic') locationIDs.push(locationID);
  });

  const latencyResultCallbacks =
    getLatencyResultCallbacks(container, locationIDs.length);
  pingLocations(locationIDs, latencyResultCallbacks);
}

function getLatencyResultCallbacks(
  container: HTMLElement,
  totalLocations: number
): PingResultCallbacks {
  let lowestLatency = Infinity;
  let errorCount = 0;

  return {
    onResult: (latencyResult: LatencyResult) => {
      updateWSLatencyOnUI(container, latencyResult);

      if (latencyResult.latency < lowestLatency) {
        lowestLatency = latencyResult.latency;
        updateAutoLocations(container, latencyResult);
      }
    },
    onError: (latencyError: LatencyError) => {
      const { locationID } = latencyError;
      updateWSErrorDisplay(container, locationID);

      errorCount++;
      if (errorCount === totalLocations) {
        updateWSErrorDisplay(container, 'automatic');
        updateVPNAutoErrorDisplay();
      }
    },
  };
}

function updateWSLatencyOnUI(
  container: HTMLElement,
  latencyResult: LatencyResult): void {
  const latencyElements =
    container.querySelectorAll<HTMLElement>(
      `[data-latency-id="${latencyResult.locationID}"]`
    );

  latencyElements.forEach((latencyElement) => {
    const loadingSpinner =
      latencyElement.querySelector<HTMLElement>('.ws-latency-spinner');
    const latencyText =
      latencyElement.querySelector<HTMLElement>('.ws-latency-text');

    if (loadingSpinner) loadingSpinner.classList.add(HIDDEN);
    if (latencyText) {
      latencyText.textContent = `${latencyResult.latency} ms`;
      latencyText.className =
        `ws-latency-text text-xs ${getLatencyColor(latencyResult.latency)}`;
      latencyText.classList.remove(HIDDEN);
    }
  });
}

function updateWSErrorDisplay(
  container: HTMLElement,
  locationId: string
): void {
  const latencyElements =
    container.querySelectorAll<HTMLElement>(
      `[data-latency-id="${locationId}"]`
    );

  latencyElements.forEach((latencyElement) => {
    const loadingSpinner =
      latencyElement.querySelector<HTMLElement>('.ws-latency-spinner');
    const latencyText =
      latencyElement.querySelector<HTMLElement>('.ws-latency-text');

    if (loadingSpinner) loadingSpinner.classList.add(HIDDEN);
    if (latencyText) {
      latencyText.innerHTML = ERROR_SVG;
      latencyText.className = 'ws-latency-text text-xs';
      latencyText.classList.remove(HIDDEN);
    }

    if (locationId === 'automatic') {
      const button = latencyElement.closest('button');
      if (button) updateButtonLabel(button, 'Error');
    }
  });
}

function updateAutoLocations(
  container: HTMLElement,
  latencyResult: LatencyResult
): void {
  setWSAutoTextAndFlag(container, latencyResult.locationID);

  const automaticLatencyResult: LatencyResult = {
    locationID: 'automatic',
    latency: latencyResult.latency,
  };

  updateWSLatencyOnUI(container, automaticLatencyResult);
  updateVPNAutoLocation(latencyResult.locationID);
}

In the assets/ts/workstations directory, create a new file named latency-utilities.ts and populate it with the below content:

latency-utilities.ts
import { ERROR_SVG, HIDDEN } from './constants';

export function getLatencyColor(latency: number): string {
  if (latency < 100) return 'text-green-400';
  if (latency < 200) return 'text-yellow-400';
  return 'text-red-400';
}

export const WS_TO_VPN_LOCATION: Record<string, string> = {
  gru: 'br-saopaulo',
  yyz: 'ca-toronto',
  iad: 'us-ashburn',
  ord: 'us-chicago',
  dfw: 'us-dallas',
  lax: 'us-losangeles',
  sjc: 'us-sanjose',
  ewr: 'us-secaucus',
  cdg: 'fr-paris',
  fra: 'de-frankfurt',
  ams: 'nl-amsterdam',
  jnb: 'za-johannesburg',
  arn: 'se-stockholm',
  lhr: 'uk-london',
  syd: 'au-sydney',
  bom: 'in-mumbai',
  nrt: 'jp-tokyo',
  sin: 'sg-singapore',
};

export function setWSAutoTextAndFlag(
  container: HTMLElement,
  locationID: string): void {
  const sourceLocationButton = container.querySelector<HTMLButtonElement>(
    `button[data-action="select"][data-value="${locationID}"]`
  );
  if (!sourceLocationButton) return;

  const automaticElement =
    container.querySelector<HTMLElement>('[data-latency-id="automatic"]');
  if (!automaticElement) return;

  const automaticButton = automaticElement.closest('button');
  if (!automaticButton) return;

  const sourceFlag = sourceLocationButton.querySelector('.country-flag-emoji');
  const automaticFlag = automaticButton.querySelector('.country-flag-emoji');

  if (sourceFlag && automaticFlag) {
    automaticFlag.textContent = sourceFlag.textContent;

    const sourceName = sourceFlag.nextElementSibling;
    const automaticName = automaticFlag.nextElementSibling;

    if (sourceName && automaticName) {
      automaticName.textContent = sourceName.textContent;
    }
  }
}

export function setVPNAutoTextAndFlag(
  autoButton: HTMLButtonElement,
  sourceButton: HTMLButtonElement): void {
  const sourceFlag = sourceButton.querySelector('.country-flag-emoji');
  const autoFlag = autoButton.querySelector('.country-flag-emoji');
  if (sourceFlag && autoFlag) {
    autoFlag.textContent = sourceFlag.textContent;

    const sourceName = sourceFlag.nextElementSibling;
    const autoName = autoFlag.nextElementSibling;

    if (sourceName && autoName) autoName.textContent = sourceName.textContent;
  }
}

export function updateVPNAutoErrorDisplay(): void {
  const autoButton = getVPNAutoButton();
  if (!autoButton) return;

  const loadingSpinner =
    autoButton.querySelector<HTMLElement>('#vpn-auto-spinner');
  if (loadingSpinner) loadingSpinner.innerHTML = ERROR_SVG;

  updateButtonLabel(autoButton, 'Error');
}

export function updateVPNAutoLocation(wsLocationId: string): void {
  const vpnLocationId = WS_TO_VPN_LOCATION[wsLocationId];
  if (!vpnLocationId) return;

  const autoButton = getVPNAutoButton();
  if (!autoButton) return;

  const vpnContainer = autoButton.closest('#vpn-location-selector');
  if (!vpnContainer) return;

  const sourceButton = vpnContainer.querySelector<HTMLButtonElement>(
    `button[data-action="select"][data-value="${vpnLocationId}"]`
  );
  if (!sourceButton) return;

  const loadingSpinner =
    autoButton.querySelector<HTMLElement>('#vpn-auto-spinner');
  if (loadingSpinner) loadingSpinner.classList.add(HIDDEN);

  setVPNAutoTextAndFlag(autoButton, sourceButton);
}

function getVPNAutoButton(): HTMLButtonElement | null {
  const vpnContainer = document.getElementById('vpn-location-selector');
  if (!vpnContainer) return null;

  return vpnContainer.querySelector<HTMLButtonElement>(
    'button[data-action="select"][data-value="automatic"]'
  );
}

export function updateButtonLabel(button: HTMLElement, text: string): void {
  const label =
    button.querySelector('.country-flag-emoji')?.nextElementSibling;
  if (label) label.textContent = text;
}

In the assets/ts/workstations directory, create a new file named location-selector.ts and populate it with the below content:

location-selector.ts
import { determineLatencies } from './latency-measurement';
import { HIDDEN, SELECTED_CLASSES } from './constants';

export function initializeWSLocation(container: HTMLElement): void {
  initializeLocation(container, '#ws-location-input');
  determineLatencies(container);
}

export function initializeVPNLocation(container: HTMLElement): void {
  initializeLocation(container, '#vpn-location-input');
}

function initializeLocation(
  container: HTMLElement,
  inputSelector: string
): void {
  const locationInput = container
    .querySelector<HTMLInputElement>(inputSelector) as HTMLInputElement;
  if (!locationInput) return;

  const views = Array.from(
    container.querySelectorAll<HTMLElement>('[data-view]')
  );
  const viewHistory: number[] = [0];

  container.addEventListener('click', (event) => {
    onClick(event);
  });

  function onClick(event: MouseEvent): void {
    const button = (event.target as Element)?.closest<HTMLButtonElement>(
      'button[data-action]'
    );
    if (!button) return;

    switch (button.dataset.action) {
      case 'select':
        select(button);
        break;
      case 'narrow':
        narrow(button);
        break;
      case 'back':
        back();
        break;
    }
  }

  function select(button: HTMLButtonElement): void {
    const value = button.dataset.value as string;
    locationInput.value = value;

    container.querySelectorAll('.selected').forEach((currentlySelected) => {
      currentlySelected.classList.remove(...SELECTED_CLASSES);
    });
    button.classList.add(...SELECTED_CLASSES);
  }

  function narrow(button: HTMLButtonElement): void {
    const targetViewIndex = Number(button.dataset.targetView);
    const targetList = button.dataset.targetList;

    viewHistory.push(targetViewIndex);
    showView(targetViewIndex, targetList);
  }

  function back(): void {
    viewHistory.pop();
    const previousView = viewHistory[viewHistory.length - 1];
    showView(previousView);
  }

  function showView(index: number, listId?: string): void {
    views.forEach((view) => view.classList.add(HIDDEN));
    views[index]?.classList.remove(HIDDEN);

    if (listId) {
      const listGroup = views[index]?.dataset.listGroup;

      container.querySelectorAll(`.${listGroup}`).forEach((element) => {
        element.classList.add(HIDDEN);
      });
      container.querySelector(`#${listId}`)?.classList.remove(HIDDEN);
    }
  }
}

In the assets/ts/workstations directory, create a new file named ping.ts and populate it with the below content:

ping.ts
const NUMBER_OF_SAMPLES = 3;

export type LatencyResult = {
  locationID: string;
  latency: number;
};

export type LatencyError = {
  locationID: string;
};

export type PingResultCallbacks = {
  onResult: (result: LatencyResult) => void;
  onError: (error: LatencyError) => void;
};

export function pingLocations(
  locationIDs: string[],
  callbacks: PingResultCallbacks
): void {
  const PING_URL =
    document.querySelector('meta[name="ping-url"]')?.getAttribute('content') ??
    'https://ping.deskterm.com';

  locationIDs.map(async (locationID) => {
    try {
      const latency = await pingLocation(locationID, PING_URL);
      callbacks.onResult({ locationID, latency });
    } catch {
      callbacks.onError({ locationID });
    }
  });
}

async function pingLocation(
  locationID: string,
  pingURL: string
): Promise<number> {
  // The initial ping is not included in the latency measurement. The initial
  // ping may be slower than subsequent pings due to one or more of the below
  // factors:
  // - DNS resolution
  // - TLS handshake
  // - Resume of the suspended fly.io microVM
  await sendPing(locationID, pingURL);

  let minLatency = Infinity;

  for (let i = 0; i < NUMBER_OF_SAMPLES; i++) {
    const latency = await sendPing(locationID, pingURL);

    if (latency < minLatency) {
      minLatency = latency;
    }
  }

  return Math.round(minLatency);
}

async function sendPing(locationID: string, pingURL: string): Promise<number> {
  const start = performance.now();

  await fetch(pingURL, {
    headers: { 'fly-force-region': locationID },
    cache: 'no-store',
  });

  return performance.now() - start;
}

We will use different ping URLs depending on the environment. To do so, update runtime.exs to dynamically configure the ping URL. Place the below code just above the if config_env() == :prod do block:

runtime.exs
ping_url =
  if System.get_env("PHX_HOST") == "deskterm.com",
    do: "https://ping.deskterm.com/",
    else: "https://deskterm-ping-test.fly.dev/"

config :deskterm, :ping_url, ping_url

In the directory lib/deskterm, create a new file named vpn_locations.ex and populate it with the below content. Note that the VPN locations are just placeholders for now. The exact locations will be determined later when we pick a VPN provider to use in our project.

vpn_locations.ex
defmodule Deskterm.VPNLocations do
  @type location :: %{
          id: String.t(),
          name: String.t()
        }

  @type country :: %{
          id: String.t(),
          name: String.t(),
          flag: String.t(),
          locations: [location()]
        }

  @type region :: %{
          id: String.t(),
          name: String.t(),
          icon: String.t(),
          countries: [country()]
        }

  @regions [
    %{
      id: "americas",
      name: "AMER",
      icon: "🌎",
      countries: [
        %{
          id: "ar",
          name: "Argentina",
          flag: "🇦🇷",
          locations: [
            %{id: "ar-buenosaires", name: "Buenos Aires"}
          ]
        },
        %{
          id: "br",
          name: "Brazil",
          flag: "🇧🇷",
          locations: [
            %{id: "br-saopaulo", name: "São Paulo"}
          ]
        },
        %{
          id: "ca",
          name: "Canada",
          flag: "🇨🇦",
          locations: [
            %{id: "ca-montreal", name: "Montreal"},
            %{id: "ca-toronto", name: "Toronto"},
            %{id: "ca-vancouver", name: "Vancouver"}
          ]
        },
        %{
          id: "cl",
          name: "Chile",
          flag: "🇨🇱",
          locations: [
            %{id: "cl-santiago", name: "Santiago"}
          ]
        },
        %{
          id: "co",
          name: "Colombia",
          flag: "🇨🇴",
          locations: [
            %{id: "co-bogota", name: "Bogotá"}
          ]
        },
        %{
          id: "cr",
          name: "Costa Rica",
          flag: "🇨🇷",
          locations: [
            %{id: "cr-sanjose", name: "San José"}
          ]
        },
        %{
          id: "cu",
          name: "Cuba",
          flag: "🇨🇺",
          locations: [
            %{id: "cu-havana", name: "Havana"}
          ]
        },
        %{
          id: "do",
          name: "Dominican Republic",
          flag: "🇩🇴",
          locations: [
            %{id: "do-santodomingo", name: "Santo Domingo"}
          ]
        },
        %{
          id: "ec",
          name: "Ecuador",
          flag: "🇪🇨",
          locations: [
            %{id: "ec-quito", name: "Quito"}
          ]
        },
        %{
          id: "sv",
          name: "El Salvador",
          flag: "🇸🇻",
          locations: [
            %{id: "sv-sansalvador", name: "San Salvador"}
          ]
        },
        %{
          id: "gt",
          name: "Guatemala",
          flag: "🇬🇹",
          locations: [
            %{id: "gt-guatemalacity", name: "Guatemala City"}
          ]
        },
        %{
          id: "hn",
          name: "Honduras",
          flag: "🇭🇳",
          locations: [
            %{id: "hn-tegucigalpa", name: "Tegucigalpa"}
          ]
        },
        %{
          id: "mx",
          name: "Mexico",
          flag: "🇲🇽",
          locations: [
            %{id: "mx-mexicocity", name: "Mexico City"},
            %{id: "mx-queretaro", name: "Querétaro"}
          ]
        },
        %{
          id: "pa",
          name: "Panama",
          flag: "🇵🇦",
          locations: [
            %{id: "pa-panamacity", name: "Panama City"}
          ]
        },
        %{
          id: "pe",
          name: "Peru",
          flag: "🇵🇪",
          locations: [
            %{id: "pe-lima", name: "Lima"}
          ]
        },
        %{
          id: "us",
          name: "United States",
          flag: "🇺🇸",
          locations: [
            %{id: "us-ashburn", name: "Ashburn, VA"},
            %{id: "us-atlanta", name: "Atlanta, GA"},
            %{id: "us-boston", name: "Boston, MA"},
            %{id: "us-charlotte", name: "Charlotte, NC"},
            %{id: "us-chicago", name: "Chicago, IL"},
            %{id: "us-columbus", name: "Columbus, OH"},
            %{id: "us-dallas", name: "Dallas, TX"},
            %{id: "us-denver", name: "Denver, CO"},
            %{id: "us-detroit", name: "Detroit, MI"},
            %{id: "us-losangeles", name: "Los Angeles, CA"},
            %{id: "us-mcallen", name: "McAllen, TX"},
            %{id: "us-memphis", name: "Memphis, TN"},
            %{id: "us-miami", name: "Miami, FL"},
            %{id: "us-newyork", name: "New York, NY"},
            %{id: "us-philadelphia", name: "Philadelphia, PA"},
            %{id: "us-phoenix", name: "Phoenix, AZ"},
            %{id: "us-saltlakecity", name: "Salt Lake City, UT"},
            %{id: "us-sanjose", name: "San Jose, CA"},
            %{id: "us-seattle", name: "Seattle, WA"},
            %{id: "us-secaucus", name: "Secaucus, NJ"},
            %{id: "us-washington", name: "Washington, DC"}
          ]
        },
        %{
          id: "ve",
          name: "Venezuela",
          flag: "🇻🇪",
          locations: [
            %{id: "ve-caracas", name: "Caracas"}
          ]
        }
      ]
    },
    %{
      id: "emea",
      name: "EMEA",
      icon: "🌍",
      countries: [
        %{
          id: "al",
          name: "Albania",
          flag: "🇦🇱",
          locations: [
            %{id: "al-tirana", name: "Tirana"}
          ]
        },
        %{
          id: "dz",
          name: "Algeria",
          flag: "🇩🇿",
          locations: [
            %{id: "dz-algiers", name: "Algiers"}
          ]
        },
        %{
          id: "ao",
          name: "Angola",
          flag: "🇦🇴",
          locations: [
            %{id: "ao-luanda", name: "Luanda"}
          ]
        },
        %{
          id: "am",
          name: "Armenia",
          flag: "🇦🇲",
          locations: [
            %{id: "am-yerevan", name: "Yerevan"}
          ]
        },
        %{
          id: "at",
          name: "Austria",
          flag: "🇦🇹",
          locations: [
            %{id: "at-vienna", name: "Vienna"}
          ]
        },
        %{
          id: "az",
          name: "Azerbaijan",
          flag: "🇦🇿",
          locations: [
            %{id: "az-baku", name: "Baku"}
          ]
        },
        %{
          id: "by",
          name: "Belarus",
          flag: "🇧🇾",
          locations: [
            %{id: "by-minsk", name: "Minsk"}
          ]
        },
        %{
          id: "be",
          name: "Belgium",
          flag: "🇧🇪",
          locations: [
            %{id: "be-brussels", name: "Brussels"}
          ]
        },
        %{
          id: "ba",
          name: "Bosnia and Herzegovina",
          flag: "🇧🇦",
          locations: [
            %{id: "ba-novitravnik", name: "Novi Travnik"}
          ]
        },
        %{
          id: "bg",
          name: "Bulgaria",
          flag: "🇧🇬",
          locations: [
            %{id: "bg-sofia", name: "Sofia"}
          ]
        },
        %{
          id: "cm",
          name: "Cameroon",
          flag: "🇨🇲",
          locations: [
            %{id: "cm-yaounde", name: "Yaoundé"}
          ]
        },
        %{
          id: "td",
          name: "Chad",
          flag: "🇹🇩",
          locations: [
            %{id: "td-ndjamena", name: "N'Djamena"}
          ]
        },
        %{
          id: "hr",
          name: "Croatia",
          flag: "🇭🇷",
          locations: [
            %{id: "hr-zagreb", name: "Zagreb"}
          ]
        },
        %{
          id: "cy",
          name: "Cyprus",
          flag: "🇨🇾",
          locations: [
            %{id: "cy-limassol", name: "Limassol"}
          ]
        },
        %{
          id: "cz",
          name: "Czech Republic",
          flag: "🇨🇿",
          locations: [
            %{id: "cz-prague", name: "Prague"}
          ]
        },
        %{
          id: "dk",
          name: "Denmark",
          flag: "🇩🇰",
          locations: [
            %{id: "dk-copenhagen", name: "Copenhagen"}
          ]
        },
        %{
          id: "eg",
          name: "Egypt",
          flag: "🇪🇬",
          locations: [
            %{id: "eg-cairo", name: "Cairo"}
          ]
        },
        %{
          id: "ee",
          name: "Estonia",
          flag: "🇪🇪",
          locations: [
            %{id: "ee-tallinn", name: "Tallinn"}
          ]
        },
        %{
          id: "fi",
          name: "Finland",
          flag: "🇫🇮",
          locations: [
            %{id: "fi-helsinki", name: "Helsinki"}
          ]
        },
        %{
          id: "fr",
          name: "France",
          flag: "🇫🇷",
          locations: [
            %{id: "fr-marseille", name: "Marseille"},
            %{id: "fr-paris", name: "Paris"}
          ]
        },
        %{
          id: "ge",
          name: "Georgia",
          flag: "🇬🇪",
          locations: [
            %{id: "ge-tbilisi", name: "Tbilisi"}
          ]
        },
        %{
          id: "de",
          name: "Germany",
          flag: "🇩🇪",
          locations: [
            %{id: "de-berlin", name: "Berlin"},
            %{id: "de-frankfurt", name: "Frankfurt"}
          ]
        },
        %{
          id: "gh",
          name: "Ghana",
          flag: "🇬🇭",
          locations: [
            %{id: "gh-accra", name: "Accra"}
          ]
        },
        %{
          id: "gr",
          name: "Greece",
          flag: "🇬🇷",
          locations: [
            %{id: "gr-athens", name: "Athens"}
          ]
        },
        %{
          id: "hu",
          name: "Hungary",
          flag: "🇭🇺",
          locations: [
            %{id: "hu-budapest", name: "Budapest"}
          ]
        },
        %{
          id: "is",
          name: "Iceland",
          flag: "🇮🇸",
          locations: [
            %{id: "is-reykjavik", name: "Reykjavik"}
          ]
        },
        %{
          id: "ie",
          name: "Ireland",
          flag: "🇮🇪",
          locations: [
            %{id: "ie-dublin", name: "Dublin"}
          ]
        },
        %{
          id: "it",
          name: "Italy",
          flag: "🇮🇹",
          locations: [
            %{id: "it-milan", name: "Milan"},
            %{id: "it-palermo", name: "Palermo"}
          ]
        },
        %{
          id: "ci",
          name: "Ivory Coast",
          flag: "🇨🇮",
          locations: [
            %{id: "ci-yamoussoukro", name: "Yamoussoukro"}
          ]
        },
        %{
          id: "lt",
          name: "Lithuania",
          flag: "🇱🇹",
          locations: [
            %{id: "lt-siauliai", name: "Siauliai"},
            %{id: "lt-vilnius", name: "Vilnius"}
          ]
        },
        %{
          id: "lu",
          name: "Luxembourg",
          flag: "🇱🇺",
          locations: [
            %{id: "lu-luxembourg", name: "Luxembourg"}
          ]
        },
        %{
          id: "md",
          name: "Moldova",
          flag: "🇲🇩",
          locations: [
            %{id: "md-chisinau", name: "Chisinau"}
          ]
        },
        %{
          id: "me",
          name: "Montenegro",
          flag: "🇲🇪",
          locations: [
            %{id: "me-podgorica", name: "Podgorica"}
          ]
        },
        %{
          id: "ma",
          name: "Morocco",
          flag: "🇲🇦",
          locations: [
            %{id: "ma-casablanca", name: "Casablanca"},
            %{id: "ma-rabat", name: "Rabat"}
          ]
        },
        %{
          id: "mz",
          name: "Mozambique",
          flag: "🇲🇿",
          locations: [
            %{id: "mz-maputo", name: "Maputo"}
          ]
        },
        %{
          id: "nl",
          name: "Netherlands",
          flag: "🇳🇱",
          locations: [
            %{id: "nl-amsterdam", name: "Amsterdam"}
          ]
        },
        %{
          id: "ng",
          name: "Nigeria",
          flag: "🇳🇬",
          locations: [
            %{id: "ng-lagos", name: "Lagos"}
          ]
        },
        %{
          id: "mk",
          name: "North Macedonia",
          flag: "🇲🇰",
          locations: [
            %{id: "mk-skopje", name: "Skopje"}
          ]
        },
        %{
          id: "no",
          name: "Norway",
          flag: "🇳🇴",
          locations: [
            %{id: "no-oslo", name: "Oslo"}
          ]
        },
        %{
          id: "ps",
          name: "Palestine",
          flag: "🇵🇸",
          locations: [
            %{id: "ps-ramallah", name: "Ramallah"}
          ]
        },
        %{
          id: "pl",
          name: "Poland",
          flag: "🇵🇱",
          locations: [
            %{id: "pl-warsaw", name: "Warsaw"}
          ]
        },
        %{
          id: "pt",
          name: "Portugal",
          flag: "🇵🇹",
          locations: [
            %{id: "pt-lisbon", name: "Lisbon"}
          ]
        },
        %{
          id: "ro",
          name: "Romania",
          flag: "🇷🇴",
          locations: [
            %{id: "ro-bucharest", name: "Bucharest"}
          ]
        },
        %{
          id: "ru",
          name: "Russia",
          flag: "🇷🇺",
          locations: [
            %{id: "ru-moscow", name: "Moscow"}
          ]
        },
        %{
          id: "rw",
          name: "Rwanda",
          flag: "🇷🇼",
          locations: [
            %{id: "rw-kigali", name: "Kigali"}
          ]
        },
        %{
          id: "sn",
          name: "Senegal",
          flag: "🇸🇳",
          locations: [
            %{id: "sn-dakar", name: "Dakar"}
          ]
        },
        %{
          id: "rs",
          name: "Serbia",
          flag: "🇷🇸",
          locations: [
            %{id: "rs-belgrade", name: "Belgrade"}
          ]
        },
        %{
          id: "sk",
          name: "Slovakia",
          flag: "🇸🇰",
          locations: [
            %{id: "sk-bratislava", name: "Bratislava"}
          ]
        },
        %{
          id: "si",
          name: "Slovenia",
          flag: "🇸🇮",
          locations: [
            %{id: "si-ljubljana", name: "Ljubljana"}
          ]
        },
        %{
          id: "za",
          name: "South Africa",
          flag: "🇿🇦",
          locations: [
            %{id: "za-johannesburg", name: "Johannesburg"}
          ]
        },
        %{
          id: "ss",
          name: "South Sudan",
          flag: "🇸🇸",
          locations: [
            %{id: "ss-juba", name: "Juba"}
          ]
        },
        %{
          id: "es",
          name: "Spain",
          flag: "🇪🇸",
          locations: [
            %{id: "es-barcelona", name: "Barcelona"},
            %{id: "es-madrid", name: "Madrid"}
          ]
        },
        %{
          id: "se",
          name: "Sweden",
          flag: "🇸🇪",
          locations: [
            %{id: "se-stockholm", name: "Stockholm"}
          ]
        },
        %{
          id: "ch",
          name: "Switzerland",
          flag: "🇨🇭",
          locations: [
            %{id: "ch-zurich", name: "Zurich"}
          ]
        },
        %{
          id: "tz",
          name: "Tanzania",
          flag: "🇹🇿",
          locations: [
            %{id: "tz-dodoma", name: "Dodoma"}
          ]
        },
        %{
          id: "tg",
          name: "Togo",
          flag: "🇹🇬",
          locations: [
            %{id: "tg-lome", name: "Lomé"}
          ]
        },
        %{
          id: "tr",
          name: "Turkey",
          flag: "🇹🇷",
          locations: [
            %{id: "tr-istanbul", name: "Istanbul"}
          ]
        },
        %{
          id: "ug",
          name: "Uganda",
          flag: "🇺🇬",
          locations: [
            %{id: "ug-kampala", name: "Kampala"}
          ]
        },
        %{
          id: "ua",
          name: "Ukraine",
          flag: "🇺🇦",
          locations: [
            %{id: "ua-kyiv", name: "Kyiv"}
          ]
        },
        %{
          id: "uk",
          name: "United Kingdom",
          flag: "🇬🇧",
          locations: [
            %{id: "uk-belfast", name: "Belfast"},
            %{id: "uk-cardiff", name: "Cardiff"},
            %{id: "uk-edinburgh", name: "Edinburgh"},
            %{id: "uk-london", name: "London"},
            %{id: "uk-manchester", name: "Manchester"}
          ]
        }
      ]
    },
    %{
      id: "apac",
      name: "APAC",
      icon: "🌏",
      countries: [
        %{
          id: "au",
          name: "Australia",
          flag: "🇦🇺",
          locations: [
            %{id: "au-adelaide", name: "Adelaide"},
            %{id: "au-brisbane", name: "Brisbane"},
            %{id: "au-melbourne", name: "Melbourne"},
            %{id: "au-perth", name: "Perth"},
            %{id: "au-sydney", name: "Sydney"}
          ]
        },
        %{
          id: "bd",
          name: "Bangladesh",
          flag: "🇧🇩",
          locations: [
            %{id: "bd-dhaka", name: "Dhaka"}
          ]
        },
        %{
          id: "bt",
          name: "Bhutan",
          flag: "🇧🇹",
          locations: [
            %{id: "bt-thimphu", name: "Thimphu"}
          ]
        },
        %{
          id: "bn",
          name: "Brunei",
          flag: "🇧🇳",
          locations: [
            %{id: "bn-bandarseribegawan", name: "Bandar Seri Begawan"}
          ]
        },
        %{
          id: "hk",
          name: "Hong Kong",
          flag: "🇭🇰",
          locations: [
            %{id: "hk-hongkong", name: "Hong Kong"}
          ]
        },
        %{
          id: "in",
          name: "India",
          flag: "🇮🇳",
          locations: [
            %{id: "in-mumbai", name: "Mumbai"}
          ]
        },
        %{
          id: "id",
          name: "Indonesia",
          flag: "🇮🇩",
          locations: [
            %{id: "id-jakarta", name: "Jakarta"}
          ]
        },
        %{
          id: "jp",
          name: "Japan",
          flag: "🇯🇵",
          locations: [
            %{id: "jp-osaka", name: "Osaka"},
            %{id: "jp-tokyo", name: "Tokyo"}
          ]
        },
        %{
          id: "kz",
          name: "Kazakhstan",
          flag: "🇰🇿",
          locations: [
            %{id: "kz-astana", name: "Astana"}
          ]
        },
        %{
          id: "la",
          name: "Laos",
          flag: "🇱🇦",
          locations: [
            %{id: "la-vientiane", name: "Vientiane"}
          ]
        },
        %{
          id: "my",
          name: "Malaysia",
          flag: "🇲🇾",
          locations: [
            %{id: "my-johorbahru", name: "Johor Bahru"},
            %{id: "my-kualalumpur", name: "Kuala Lumpur"}
          ]
        },
        %{
          id: "mn",
          name: "Mongolia",
          flag: "🇲🇳",
          locations: [
            %{id: "mn-ulaanbaatar", name: "Ulaanbaatar"}
          ]
        },
        %{
          id: "np",
          name: "Nepal",
          flag: "🇳🇵",
          locations: [
            %{id: "np-kathmandu", name: "Kathmandu"}
          ]
        },
        %{
          id: "nz",
          name: "New Zealand",
          flag: "🇳🇿",
          locations: [
            %{id: "nz-auckland", name: "Auckland"}
          ]
        },
        %{
          id: "om",
          name: "Oman",
          flag: "🇴🇲",
          locations: [
            %{id: "om-muscat", name: "Muscat"}
          ]
        },
        %{
          id: "pk",
          name: "Pakistan",
          flag: "🇵🇰",
          locations: [
            %{id: "pk-karachi", name: "Karachi"}
          ]
        },
        %{
          id: "ph",
          name: "Philippines",
          flag: "🇵🇭",
          locations: [
            %{id: "ph-manila", name: "Manila"}
          ]
        },
        %{
          id: "qa",
          name: "Qatar",
          flag: "🇶🇦",
          locations: [
            %{id: "qa-doha", name: "Doha"}
          ]
        },
        %{
          id: "sg",
          name: "Singapore",
          flag: "🇸🇬",
          locations: [
            %{id: "sg-singapore", name: "Singapore"}
          ]
        },
        %{
          id: "kr",
          name: "South Korea",
          flag: "🇰🇷",
          locations: [
            %{id: "kr-seoul", name: "Seoul"}
          ]
        },
        %{
          id: "lk",
          name: "Sri Lanka",
          flag: "🇱🇰",
          locations: [
            %{id: "lk-colombo", name: "Colombo"}
          ]
        },
        %{
          id: "tj",
          name: "Tajikistan",
          flag: "🇹🇯",
          locations: [
            %{id: "tj-dushanbe", name: "Dushanbe"}
          ]
        },
        %{
          id: "th",
          name: "Thailand",
          flag: "🇹🇭",
          locations: [
            %{id: "th-bangkok", name: "Bangkok"}
          ]
        },
        %{
          id: "tm",
          name: "Turkmenistan",
          flag: "🇹🇲",
          locations: [
            %{id: "tm-ashgabat", name: "Ashgabat"}
          ]
        },
        %{
          id: "uz",
          name: "Uzbekistan",
          flag: "🇺🇿",
          locations: [
            %{id: "uz-tashkent", name: "Tashkent"}
          ]
        }
      ]
    }
  ]

  def regions, do: @regions
end

In the directory lib/deskterm, create a new file named workstation_locations.ex and populate it with the below content. The locations are based on the Fly.io Regions.

workstation_locations.ex
defmodule Deskterm.WorkstationLocations do
  @type region :: %{
          id: String.t(),
          name: String.t(),
          icon: String.t(),
          locations: [location()]
        }

  @type location :: %{
          id: String.t(),
          name: String.t(),
          flag: String.t()
        }

  @regions [
    %{
      id: "amer",
      name: "AMER",
      icon: "🌎",
      locations: [
        %{id: "gru", name: "São Paulo", flag: "🇧🇷"},
        %{id: "yyz", name: "Toronto", flag: "🇨🇦"},
        %{id: "iad", name: "Ashburn, VA", flag: "🇺🇸"},
        %{id: "ord", name: "Chicago, IL", flag: "🇺🇸"},
        %{id: "dfw", name: "Dallas, TX", flag: "🇺🇸"},
        %{id: "lax", name: "Los Angeles, CA", flag: "🇺🇸"},
        %{id: "sjc", name: "San Jose, CA", flag: "🇺🇸"},
        %{id: "ewr", name: "Secaucus, NJ", flag: "🇺🇸"}
      ]
    },
    %{
      id: "emea",
      name: "EMEA",
      icon: "🌍",
      locations: [
        %{id: "cdg", name: "Paris", flag: "🇫🇷"},
        %{id: "fra", name: "Frankfurt", flag: "🇩🇪"},
        %{id: "ams", name: "Amsterdam", flag: "🇳🇱"},
        %{id: "jnb", name: "Johannesburg", flag: "🇿🇦"},
        %{id: "arn", name: "Stockholm", flag: "🇸🇪"},
        %{id: "lhr", name: "London", flag: "🇬🇧"}
      ]
    },
    %{
      id: "apac",
      name: "APAC",
      icon: "🌏",
      locations: [
        %{id: "syd", name: "Sydney", flag: "🇦🇺"},
        %{id: "bom", name: "Mumbai", flag: "🇮🇳"},
        %{id: "nrt", name: "Tokyo", flag: "🇯🇵"},
        %{id: "sin", name: "Singapore", flag: "🇸🇬"}
      ]
    }
  ]

  def regions, do: @regions
end

Update loading_spinner.html.heex to take an id as a parameter. This way we can ensure that each loading spinner has a unique id. On the workstation selection screen there may be multiple loading spinners displaying simultaneously. Add the below line as the second line of the file:

loading_spinner.html.heex
id={"loading-spinner-#{@id}"}

Update the file root.html.heex to include a meta HTML tag that includes the ping URL. The TypeScript client code will pick up this ping URL and use it for pinging. The ping URL is determined on the Elixir (server side) but the actual pinging happens in the TypeScript code (client side). In the <head> section, add the below code:

root.html.heex
<meta name="ping-url" content={Application.get_env(:deskterm, :ping_url)} />

Update app_live.ex to initially set the overview_view to browsers. This causes the list of available browsers to be shown to the user initially when navigating to the overview view.

app_live.ex
@impl true
def mount(_params, session, socket) do
  {:ok,
    assign(socket,
      browsers: Workstations.get_browsers(),
      email: nil,
      is_verifying: false,
      loading: nil,
      modal: Authentication.get_modal(),
      otp_code: %{},
      overview_view: "browsers",
      session_data: session,
      should_show_modal: false,
      view: "login"
    )}
end

Additionally, add the below function to handle the new select_workstation event:

app_live.ex
@impl true
def handle_event(
      "select_workstation",
      %{"workstation" => workstation},
      socket
    ) do
  socket =
    socket
    |> assign(
      overview_view: "launch",
      workstation: workstation
    )
    |> Phoenix.LiveView.clear_flash()

  {:noreply, socket}
end

Next, update app_live.html.heex to pass the overview_view to the Overview view:

app_live.html.heex
<DesktermWeb.Views.overview
  browsers={@browsers}
  overview_view={@overview_view}
/>

Update the usages of the loading spinner in login.html.heex to pass an id for each loading spinner, for example:

login.html.heex
<DesktermWeb.AppComponents.loading_spinner
  color="white"
  id={@loading}
/>

In the directory lib/deskterm_web/views/templates, create a new file named launch.html.heex and populate it with the below content:

launch.html.heex
<form id="launch" phx-submit="start_workstation" class="flex flex-col h-full">
  <div class="flex-1 overflow-y-auto space-y-4 scrollbar-none">

<!-- Workstation Location -->
    <div class="bg-gray-900/50 rounded-2xl p-4 border border-white/10">
      <div id="region-selector" phx-hook="WSLocation">
        <label class="block text-xl font-medium text-gray-300 mb-2">
          Workstation Location
        </label>
        <input
          type="hidden"
          name="region"
          id="ws-location-input"
          value="automatic"
          required
        />
        <div id="ws-region-view" data-view>
          <h4 class="text-xs font-semibold text-indigo-400 mb-2">
            Automatic
          </h4>
          <div class="
              grid
              grid-cols-1
              sm:grid-cols-2
              md:grid-cols-3
              xl:grid-cols-4
              2xl:grid-cols-5
              gap-3
              mb-4">
            <button
              type="button"
              class="
                ws-location
                selected
                flex
                items-center
                justify-between
                p-3
                bg-gray-800
                border
                border-white/10
                rounded-lg
                hover:ring-2
                hover:ring-blue-500
                focus:ring-2
                focus:ring-blue-500
                ring-2
                ring-blue-500
                transition-all
                cursor-pointer
                hover:scale-105"
              data-value="automatic"
              data-action="select"
            >
              <span class="flex items-center gap-2">
                <span class="country-flag-emoji text-2xl">
                  🌐
                </span>
                <span class="text-xs text-gray-300">
                  TBD
                </span>
              </span>
              <span data-latency-id="automatic">
                <span class="ws-latency-spinner">
                  <DesktermWeb.AppComponents.loading_spinner
                    color="white"
                    id="ws-automatic"
                  />
                </span>
                <span class="ws-latency-text text-xs text-gray-400 hidden">
                  <span class="ws-latency-text-placeholder"></span>
                </span>
              </span>
            </button>
          </div>
          <h4 class="text-xs font-semibold text-indigo-400 mb-2">
            Select Region
          </h4>
          <div class="
              grid
              grid-cols-1
              sm:grid-cols-2
              md:grid-cols-3
              xl:grid-cols-4
              2xl:grid-cols-5
              gap-3">
            <%= for region <- DesktermWeb.Views.workstation_regions() do %>
              <button
                type="button"
                class="
                  ws-location
                  flex
                  items-center
                  justify-between
                  p-3
                  bg-gray-800
                  border
                  border-white/10
                  rounded-lg
                  hover:ring-2
                  hover:ring-blue-500
                  focus:ring-2
                  focus:ring-blue-500
                  transition-all
                  cursor-pointer
                  hover:scale-105"
                data-action="narrow"
                data-target-view="1"
                data-target-list={"ws-location-#{region.id}"}
              >
                <span class="flex items-center gap-2">
                  <span class="text-2xl">{region.icon}</span>
                  <span class="text-xs text-gray-300">{region.name}</span>
                </span>
              </button>
            <% end %>
          </div>
        </div>
        <div
          id="ws-location-view"
          data-view
          data-list-group="ws-location-list"
          class="hidden"
        >
          <button
            type="button"
            class="
              ws-back-btn
              flex
              items-center
              text-green-400
              hover:text-green-300
              mb-3
              cursor-pointer"
            data-action="back"
          >
            <span class="text-4xl"></span>
          </button>
          <%= for region <- DesktermWeb.Views.workstation_regions() do %>
            <div
              id={"ws-location-#{region.id}"}
              class="ws-location-list hidden"
            >
              <h4 class="text-xs font-semibold text-indigo-400 mb-2">
                {region.name}
              </h4>
              <div class="
                  grid
                  grid-cols-1
                  sm:grid-cols-2
                  md:grid-cols-3
                  xl:grid-cols-4
                  2xl:grid-cols-5
                  gap-3">
                <%= for location <- region.locations do %>
                  <button
                    type="button"
                    class="
                      ws-location
                      flex
                      items-center
                      justify-between
                      p-3
                      bg-gray-800
                      border
                      border-white/10
                      rounded-lg
                      hover:ring-2
                      hover:ring-blue-500
                      focus:ring-2
                      focus:ring-blue-500
                      transition-all
                      cursor-pointer
                      hover:scale-105"
                    data-value={location.id}
                    data-action="select"
                  >
                    <span class="flex items-center gap-2">
                      <span class="country-flag-emoji text-2xl">
                        {location.flag}
                      </span>
                      <span class="text-xs text-gray-300">
                        {location.name}
                      </span>
                    </span>
                    <span data-latency-id={location.id}>
                      <span class="ws-latency-spinner">
                        <DesktermWeb.AppComponents.loading_spinner
                          color="white"
                          id={"ws-#{location.id}"}
                        />
                      </span>
                      <span class="ws-latency-text text-xs text-gray-400 hidden">
                      </span>
                    </span>
                  </button>
                <% end %>
              </div>
            </div>
          <% end %>
        </div>
      </div>
    </div>

<!-- VPN Location -->
    <div class="bg-gray-900/50 rounded-2xl p-4 border border-white/10">
      <div id="vpn-location-selector" phx-hook="VPNLocation">
        <label class="block text-xl font-medium text-gray-300 mb-2">
          VPN Location
        </label>
        <input
          type="hidden"
          name="vpn_location"
          id="vpn-location-input"
          value="automatic"
          required
        />
        <div id="vpn-region-view" data-view>
          <h4 class="text-xs font-semibold text-indigo-400 mb-2">
            Automatic
          </h4>
          <div class="
              grid
              grid-cols-1
              sm:grid-cols-2
              md:grid-cols-3
              xl:grid-cols-4
              2xl:grid-cols-5
              gap-3
              mb-4">
            <button
              type="button"
              class="
                vpn-location
                selected
                flex
                items-center
                justify-between
                p-3
                bg-gray-800
                border
                border-white/10
                rounded-lg
                hover:ring-2
                hover:ring-blue-500
                focus:ring-2
                focus:ring-blue-500
                ring-2
                ring-blue-500
                transition-all
                cursor-pointer
                hover:scale-105"
              data-value="automatic"
              data-action="select"
            >
              <span class="flex items-center gap-2">
                <span class="country-flag-emoji text-2xl">🌐</span>
                <span class="text-xs text-gray-300">TBD</span>
              </span>
              <span id="vpn-auto-spinner">
                <DesktermWeb.AppComponents.loading_spinner
                  color="white"
                  id="vpn-automatic"
                />
              </span>
            </button>
          </div>
          <h4 class="text-xs font-semibold text-indigo-400 mb-2">
            Select Region
          </h4>
          <div class="
              grid
              grid-cols-1
              sm:grid-cols-2
              md:grid-cols-3
              xl:grid-cols-4
              2xl:grid-cols-5
              gap-3">
            <%= for region <- DesktermWeb.Views.vpn_regions() do %>
              <button
                type="button"
                class="
                  vpn-location
                  flex
                  items-center
                  justify-between
                  p-3
                  bg-gray-800
                  border
                  border-white/10
                  rounded-lg
                  hover:ring-2
                  hover:ring-blue-500
                  focus:ring-2
                  focus:ring-blue-500
                  transition-all
                  cursor-pointer
                  hover:scale-105"
                data-action="narrow"
                data-target-view="1"
                data-target-list={"vpn-country-#{region.id}"}
              >
                <span class="flex items-center gap-2">
                  <span class="text-2xl">{region.icon}</span>
                  <span class="text-xs text-gray-300">{region.name}</span>
                </span>
              </button>
            <% end %>
          </div>
        </div>
        <div
          id="vpn-country-view"
          data-view
          data-list-group="vpn-country-list"
          class="hidden"
        >
          <button
            type="button"
            class="
              vpn-back-btn
              flex
              items-center
              text-green-400
              hover:text-green-300
              mb-3
              cursor-pointer"
            data-action="back"
          >
            <span class="text-4xl"></span>
          </button>
          <%= for region <- DesktermWeb.Views.vpn_regions() do %>
            <div
              id={"vpn-country-#{region.id}"}
              class="vpn-country-list hidden"
            >
              <h4 class="text-xs font-semibold text-indigo-400 mb-2">
                {region.name}
              </h4>
              <div class="
                  grid
                  grid-cols-1
                  sm:grid-cols-2
                  md:grid-cols-3
                  xl:grid-cols-4
                  2xl:grid-cols-5
                  gap-3">
                <%= for country <- region.countries do %>
                  <button
                    type="button"
                    class="
                      vpn-location
                      flex
                      items-center
                      justify-between
                      p-3
                      bg-gray-800
                      border
                      border-white/10
                      rounded-lg
                      hover:ring-2
                      hover:ring-blue-500
                      focus:ring-2
                      focus:ring-blue-500
                      transition-all
                      cursor-pointer
                      hover:scale-105"
                    data-action="narrow"
                    data-target-view="2"
                    data-target-list={"vpn-location-#{country.id}"}
                  >
                    <span class="flex items-center gap-2">
                      <span class="country-flag-emoji text-2xl">
                        {country.flag}
                      </span>
                      <span class="text-xs text-gray-300">
                        {country.name}
                      </span>
                    </span>
                  </button>
                <% end %>
              </div>
            </div>
          <% end %>
        </div>
        <div
          id="vpn-location-view"
          data-view
          data-list-group="vpn-location-list"
          class="hidden"
        >
          <button
            type="button"
            class="
              vpn-back-btn
              flex
              items-center
              text-green-400
              hover:text-green-300
              mb-3
              cursor-pointer"
            data-action="back"
          >
            <span class="text-4xl"></span>
          </button>
          <%= for region <- DesktermWeb.Views.vpn_regions() do %>
            <%= for country <- region.countries do %>
              <div
                id={"vpn-location-#{country.id}"}
                class="vpn-location-list hidden"
              >
                <h4 class="text-xs font-semibold text-indigo-400 mb-2">
                  {country.name}
                </h4>
                <div class="
                    grid
                    grid-cols-1
                    sm:grid-cols-2
                    md:grid-cols-3
                    xl:grid-cols-4
                    2xl:grid-cols-5
                    gap-3">
                  <%= for location <- country.locations do %>
                    <button
                      type="button"
                      class="
                        vpn-location
                        flex
                        items-center
                        justify-between
                        p-3
                        bg-gray-800
                        border
                        border-white/10
                        rounded-lg
                        hover:ring-2
                        hover:ring-blue-500
                        focus:ring-2
                        focus:ring-blue-500
                        transition-all
                        cursor-pointer
                        hover:scale-105"
                      data-value={location.id}
                      data-action="select"
                    >
                      <span class="flex items-center gap-2">
                        <span class="country-flag-emoji text-2xl">
                          {country.flag}
                        </span>
                        <span class="text-xs text-gray-300">
                          {location.name}
                        </span>
                      </span>
                    </button>
                  <% end %>
                </div>
              </div>
            <% end %>
          <% end %>
        </div>
      </div>
    </div>

<!-- Legal -->
    <div class="bg-gray-900/50 rounded-2xl p-4 border border-white/10">
      <label class="block text-xl font-medium text-gray-300 mb-4">
        Legal
      </label>
      <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
        <div class="space-y-3">
          <label class="
              flex
              items-center
              gap-3
              text-md
              text-gray-300
              cursor-pointer">
            <input
              type="checkbox"
              name="accept_terms_of_service"
              required
              class="
                w-6
                h-6
                shrink-0
                rounded
                border-gray-600
                bg-gray-800
                text-blue-600
                focus:ring-blue-500
                focus:ring-offset-gray-900"
            />
            <span>
              I agree to the
              <a
                href="/terms-of-service"
                target="_blank"
                rel="noopener noreferrer"
                class="text-blue-400 hover:underline"
              >
                Terms of Service
              </a>
            </span>
          </label>
          <label class="
              flex
              items-center
              gap-3
              text-md
              text-gray-300
              cursor-pointer">
            <input
              type="checkbox"
              name="accept_acceptable_use_policy"
              required
              class="
                w-6
                h-6
                shrink-0
                rounded
                border-gray-600
                bg-gray-800
                text-blue-600
                focus:ring-blue-500
                focus:ring-offset-gray-900"
            />
            <span>
              I agree to the
              <a
                href="/acceptable-use-policy"
                target="_blank"
                rel="noopener noreferrer"
                class="text-blue-400 hover:underline"
              >
                Acceptable Use Policy
              </a>
            </span>
          </label>
        </div>
        <div class="space-y-3">
          <label class="
              flex
              items-center
              gap-3
              text-md
              text-gray-300
              cursor-pointer">
            <input
              type="checkbox"
              name="accept_privacy_policy"
              required
              class="
                w-6
                h-6
                shrink-0
                rounded
                border-gray-600
                bg-gray-800
                text-blue-600
                focus:ring-blue-500
                focus:ring-offset-gray-900"
            />
            <span>
              I agree to the
              <a
                href="/privacy-policy"
                target="_blank"
                rel="noopener noreferrer"
                class="text-blue-400 hover:underline"
              >
                Privacy Policy
              </a>
            </span>
          </label>
          <label class="
              flex
              items-center
              gap-3
              text-md
              text-gray-300
              cursor-pointer">
            <input
              type="checkbox"
              name="accept_cookie_policy"
              required
              class="
                w-6
                h-6
                shrink-0
                rounded
                border-gray-600
                bg-gray-800
                text-blue-600
                focus:ring-blue-500
                focus:ring-offset-gray-900"
            />
            <span>
              I agree to the
              <a
                href="/cookie-policy"
                target="_blank"
                rel="noopener noreferrer"
                class="text-blue-400 hover:underline"
              >
                Cookie Policy
              </a>
            </span>
          </label>
        </div>
      </div>
    </div>
  </div>

<!-- Start Workstation -->
  <div class="shrink-0 pt-4 pb-2 px-2">
    <button
      type="submit"
      class="
        w-full
        px-4
        py-1.5
        rounded-md
        text-white
        bg-indigo-500
        hover:bg-indigo-400
        hover:scale-102
        transition-all
        focus-visible:ring-2
        focus-visible:ring-indigo-400
        cursor-pointer"
    >
      Start Workstation
    </button>
  </div>
</form>

Modify overview.html.heex to conditionally show either the workstations or launch view in the Content section:

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

<%= if @overview_view == "launch" do %>
  <DesktermWeb.Views.launch />
<% end %>

Now modify workstations.html.heex to have an id and to also perform an action when a workstation is selected. Furthermore we will adjust the number of columns for better presentation of the workstations. You can replace the whole content of the file with the below content:

workstations.html.heex
<ul
  id="workstations"
  class="
    grid
    grid-cols-3
    sm:grid-cols-6
    md:grid-cols-7
    lg:grid-cols-8
    xl:grid-cols-10
    2xl:grid-cols-14
    gap-4
    bg-gray-900/50
    rounded-2xl
    p-4
    border
    border-white/10"
>
  <li
    :for={workstation <- @workstations}
    class="
      flex
      flex-col
      items-center
      gap-2
      rounded-2xl
      py-4
      px-4
      cursor-pointer
      transition-transform
      duration-200
      hover:scale-110"
    phx-click="select_workstation"
    phx-value-workstation={workstation}
  >
    <img
      src={"/images/logos/workstations/#{workstation}.svg"}
      alt={String.capitalize(workstation)}
      class="w-full"
    />
    <span class="text-white text-sm">{String.capitalize(workstation)}</span>
  </li>
</ul>

Add the below code to the DesktermWeb.View module in the file views.ex:

views.ex
def vpn_regions, do: Deskterm.VPNLocations.regions()
def workstation_regions, do: Deskterm.WorkstationLocations.regions()

When pinging a location fails, we will display a small error icon. Download the Error icon as an SVG file from uxwing and place it in the directory priv/static/images/icons of your Phoenix project. Name the new file error.svg.

Add the new provision-test-ping-fleet and provision-production-ping-fleet stages to .gitlab-ci.yml. To do so, replace the content of the file with the below content:

.gitlab-ci.yml
variables:
  PING_FLEET_REGIONS: ams,arn,bom,cdg,dfw,ewr,fra,gru,iad,jnb,lax,lhr,nrt,ord,sin,sjc,syd,yyz

stages:
  - lint-web
  - build-web
  - test-web
  - provision-test-ping-fleet
  - provision-test-environment
  - provision-production-ping-fleet
  - provision-production-environment
  - docker-test-environment
  - docker-production-environment
  - deploy-test-environment
  - deploy-production-environment
  - release-web
  - documentation

include:
  - local: "ci/build-web.yml"
  - local: "ci/deploy-production-environment.yml"
  - local: "ci/deploy-test-environment.yml"
  - local: "ci/docker-production-environment.yml"
  - local: "ci/docker-test-environment.yml"
  - local: "ci/documentation.yml"
  - local: "ci/lint-web.yml"
  - local: "ci/provision-production-environment.yml"
  - local: "ci/provision-production-ping-fleet.yml"
  - local: "ci/provision-test-environment.yml"
  - local: "ci/provision-test-ping-fleet.yml"
  - local: "ci/release-web.yml"
  - local: "ci/test-web.yml"

workflow:
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
    - if:  $CI_PIPELINE_SOURCE == "merge_request_event"
    - when: never

For better code organization, we will create a new directory for all Terraform related files. In the directory iac of your git repository, create a new directory named terraform. Inside this directory move the below list of files. Those files are currently located under the directory iac.

  • .terraform.lock.hcl
  • main.tf
  • production.tfvars
  • test.tfvars
  • user_data.yml
  • variables.tf

Next we need to update the below files to cd into this new directory for the Terraform operations:

The cd line in those files should be updated to be:

- cd iac/terraform

In the ci directory of your git repository, create a new file named provision-production-ping-fleet.yml and populate it with the below content:

provision-production-ping-fleet.yml
provision-production-ping-fleet:
  before_script:
    - curl -L https://fly.io/install.sh | sh
    - export FLYCTL_INSTALL="/root/.fly"
    - export PATH="$FLYCTL_INSTALL/bin:$PATH"
  image: fedora:latest
  script:
    - cd iac/fly.io
    - fly deploy --config ping.toml --app deskterm-ping-production --ha=false
    - REGION_COUNT=$(echo "$PING_FLEET_REGIONS" | tr ',' '\n' | wc -l)
    - fly scale count $REGION_COUNT --region $PING_FLEET_REGIONS --app deskterm-ping-production --yes
  stage: provision-production-ping-fleet

In the ci directory of your git repository, create a new file named provision-test-ping-fleet.yml and populate it with the below content:

provision-test-ping-fleet.yml
provision-test-ping-fleet:
  before_script:
    - curl -L https://fly.io/install.sh | sh
    - export FLYCTL_INSTALL="/root/.fly"
    - export PATH="$FLYCTL_INSTALL/bin:$PATH"
  image: fedora:latest
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  script:
    - cd iac/fly.io
    - fly deploy --config ping.toml --app deskterm-ping-test --ha=false
    - REGION_COUNT=$(echo "$PING_FLEET_REGIONS" | tr ',' '\n' | wc -l)
    - fly scale count $REGION_COUNT --region $PING_FLEET_REGIONS --app deskterm-ping-test --yes
  stage: provision-test-ping-fleet

We will now create a Dockerfile for our lightweight ping machines. They will run an nginx server hosting a simple file. In the root directory of your git repository, create a new directory named docker. Inside this new directory, create a new file named ping.dockerfile and populate it with the below content:

ping.dockerfile
FROM nginx:stable
COPY ping.nginx.conf /etc/nginx/conf.d/default.conf
RUN echo "Deskterm latency test endpoint" > /usr/share/nginx/html/index.html
EXPOSE 8080

We will now create a configuration file for the nginx server. In the iac directory of your git repository, create a new directory named fly.io. Inside this new directory create a file named ping.nginx.conf and populate it with the below content:

ping.nginx.conf
server {
    listen 8080;

    location / {
        root /usr/share/nginx/html;

        add_header Access-Control-Allow-Origin *;
        add_header Access-Control-Allow-Headers fly-force-region;
        add_header Access-Control-Allow-Methods "GET, OPTIONS";

        if ($request_method = OPTIONS) {
            return 204;
        }
    }
}

Now we will create the Fly.io configuration file for our ping fleet. In the iac/fly.io directory of your git repository, create a new file named ping.toml and populate it with the below content:

ping.toml
primary_region = 'arn'

[build]
  dockerfile = "../../docker/ping.dockerfile"

[http_service]
  internal_port = 8080
  force_https = true
  auto_stop_machines = 'suspend'
  auto_start_machines = true
  min_machines_running = 0

  [http_service.concurrency]
    type = 'requests'
    soft_limit = 250
    hard_limit = 250

[[restart]]
  policy = 'always'
  retries = 10

[[vm]]
  size = "shared-cpu-1x"