diff --git a/.github/scripts/fail_on_changed_line_lint.mjs b/.github/scripts/fail_on_changed_line_lint.mjs new file mode 100644 index 0000000000..ebe8d450f2 --- /dev/null +++ b/.github/scripts/fail_on_changed_line_lint.mjs @@ -0,0 +1,142 @@ +// Copyright 2026 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {existsSync, readFileSync} from 'node:fs'; +import path from 'node:path'; +import {spawnSync} from 'node:child_process'; + +function getRequiredEnv(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required`); + } + return value; +} + +function parseFileList(value) { + return value.trim().split(/\s+/).filter(Boolean); +} + +function normalizePath(filePath) { + return path.isAbsolute(filePath) + ? path.relative(process.cwd(), filePath) + : filePath; +} + +function getChangedLineSet(baseCommit, headCommit, file) { + const result = spawnSync( + 'git', + ['diff', '--unified=0', '--no-color', baseCommit, headCommit, '--', file], + {encoding: 'utf8'} + ); + if (result.status !== 0) { + throw new Error(`git diff failed for ${file}: ${result.stderr}`); + } + + const lines = new Set(); + const hunkRegExp = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/; + for (const line of result.stdout.split('\n')) { + const match = line.match(hunkRegExp); + if (!match) { + continue; + } + const start = Number.parseInt(match[1], 10); + const count = Number.parseInt(match[2] || '1', 10); + for (let i = 0; i < count; i++) { + lines.add(start + i); + } + } + return lines; +} + +function getLine(message, key) { + const line = message[key]; + return Number.isInteger(line) ? line : null; +} + +function main() { + const baseCommit = getRequiredEnv('BASE_COMMIT'); + const headCommit = getRequiredEnv('HEAD_COMMIT'); + const lintResultsPath = process.env.LINT_RESULTS_PATH || 'lint-results.json'; + const files = [ + ...parseFileList(process.env.MODIFIED_FILES || ''), + ...parseFileList(process.env.ADDED_FILES || ''), + ]; + + if (!files.length) { + return; + } + if (!existsSync(lintResultsPath)) { + throw new Error(`Missing lint results file: ${lintResultsPath}`); + } + + const changedLinesByFile = new Map( + files.map(file => [file, getChangedLineSet(baseCommit, headCommit, file)]) + ); + const lintResults = JSON.parse(readFileSync(lintResultsPath, 'utf8')); + const blockingIssues = []; + + for (const fileResult of lintResults) { + const file = normalizePath(fileResult.filePath); + const changedLines = changedLinesByFile.get(file); + if (!changedLines) { + continue; + } + + for (const message of fileResult.messages || []) { + if (!(message.severity === 2 || message.fatal)) { + continue; + } + + const line = getLine(message, 'line'); + const endLine = getLine(message, 'endLine') || line; + if (!line) { + // File-level parse/config errors. + blockingIssues.push({file, line: 1, message}); + continue; + } + + let intersectsChangedLines = false; + for (let current = line; current <= endLine; current++) { + if (changedLines.has(current)) { + intersectsChangedLines = true; + break; + } + } + if (intersectsChangedLines) { + blockingIssues.push({file, line, message}); + } + } + } + + if (!blockingIssues.length) { + console.log('No lint errors found on changed lines.'); + return; + } + + for (const issue of blockingIssues) { + const rule = issue.message.ruleId || 'lint'; + const text = issue.message.message.replace(/\r?\n/g, ' '); + console.log( + `::error file=${issue.file},line=${issue.line},title=${rule}::${text}` + ); + } + + console.error( + `Found ${blockingIssues.length} lint error(s) on changed lines.` + ); + process.exit(1); +} + +main(); diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 796ef5b436..1961add793 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -34,6 +34,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v2.3.4 + with: + fetch-depth: 0 - name: Install Node uses: actions/setup-node@v3 @@ -55,7 +57,35 @@ jobs: **/*.cjs **/*.mjs - - name: Lint + - name: Set base and head commits + id: commits + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + base_commit="${{ github.event.pull_request.base.sha }}" + else + base_commit="${{ github.event.before }}" + # Handle edge cases where "before" is empty or all-zero (e.g. first push). + if [[ -z "$base_commit" || "$base_commit" =~ ^0+$ ]]; then + base_commit="$(git rev-list --max-parents=0 HEAD)" + fi + fi + echo "base_commit=$base_commit" >> "$GITHUB_OUTPUT" + echo "head_commit=${{ github.sha }}" >> "$GITHUB_OUTPUT" + + - name: Lint changed files + if: ${{ steps.changed-files.outputs.modified_files_count > 0 || steps.changed-files.outputs.added_file_count > 0 }} + run: | + npx eslint -f json -o lint-results.json \ + ${{ steps.changed-files.outputs.modified_files }} \ + ${{ steps.changed-files.outputs.added_files }} || true + + - name: Fail on issues in changed lines if: ${{ steps.changed-files.outputs.modified_files_count > 0 || steps.changed-files.outputs.added_file_count > 0 }} - run: npx gts lint ${{ steps.changed-files.outputs.modified_files }} ${{ steps.changed-files.outputs.added_files }} + env: + BASE_COMMIT: ${{ steps.commits.outputs.base_commit }} + HEAD_COMMIT: ${{ steps.commits.outputs.head_commit }} + MODIFIED_FILES: ${{ steps.changed-files.outputs.modified_files }} + ADDED_FILES: ${{ steps.changed-files.outputs.added_files }} + LINT_RESULTS_PATH: lint-results.json + run: node .github/scripts/fail_on_changed_line_lint.mjs diff --git a/server_manager/README.md b/server_manager/README.md index 9557cf4ef8..f9ef817365 100644 --- a/server_manager/README.md +++ b/server_manager/README.md @@ -26,6 +26,42 @@ To run the Outline Manager as a web app on the browser and listen for changes: npm run action server_manager/www/start ``` +## Updating Cloud Locations + +When new cloud regions/zones appear (especially for GCP), update all of the +following so the location picker shows proper city and country data. + +1. Add geolocation constants in `server_manager/model/location.ts`. +2. Map cloud region IDs to `GeoLocation` in `server_manager/model/gcp.ts` + (`Zone.LOCATION_MAP`). +3. Add localized city message keys: + - Source messages: `server_manager/messages/master_messages.json` using + `geo_*` keys. + - Runtime English messages: `server_manager/messages/en.json` and + `server_manager/messages/en-GB.json` using `geo-*` keys. +4. Keep all `geo_*` / `geo-*` message keys alphabetically sorted. +5. Update tests: + - `server_manager/model/gcp.spec.ts` region coverage list and assertions. + - `server_manager/www/location_formatting.spec.ts` if formatting/sorting + behavior changes. +6. Verify: + +```bash +npx tsc -p server_manager --outDir output/build/js/server_manager --module commonjs +npx jasmine output/build/js/server_manager/model/gcp.spec.js output/build/js/server_manager/www/location_formatting.spec.js +npm run action server_manager/www/test +``` + +Notes: +- Missing entries in `Zone.LOCATION_MAP` cause unknown location cards (`?` and + raw zone IDs) in the picker. +- Region discovery can be checked programmatically with: + +```bash +gcloud compute regions list --format="value(name)" +gcloud compute zones list --format="value(name)" +``` + ## Debug an existing binary You can run an existing binary in debug mode by setting `OUTLINE_DEBUG=true`. diff --git a/server_manager/install_scripts/gcp_install_server.sh b/server_manager/install_scripts/gcp_install_server.sh index 2d22dcde7a..c258f6053e 100755 --- a/server_manager/install_scripts/gcp_install_server.sh +++ b/server_manager/install_scripts/gcp_install_server.sh @@ -43,7 +43,7 @@ function log_for_sentry() { } function post_sentry_report() { - if [[ -n "${SENTRY_API_URL}" ]]; then + if [[ -n "${SENTRY_API_URL:-}" ]]; then # Get JSON formatted string. This command replaces newlines with literal '\n' # but otherwise assumes that there are no other characters to escape for JSON. # If we need better escaping, we can install the jq command line tool. @@ -69,7 +69,6 @@ function cloud::set_guest_attribute() { cloud::set_guest_attribute "install-started" "true" # Enable BBR. -# Recent DigitalOcean one-click images are based on Ubuntu 18 and have kernel 4.15+. log_for_sentry "Enabling BBR" cat >> /etc/sysctl.conf << EOF @@ -101,15 +100,15 @@ log_for_sentry "Downloading Docker" # Following instructions from https://docs.docker.com/engine/install/ubuntu/#install-from-a-package declare -ar PACKAGES=( - 'containerd.io_1.4.9-1_amd64.deb' - 'docker-ce_20.10.8~3-0~ubuntu-focal_amd64.deb' - 'docker-ce-cli_20.10.8~3-0~ubuntu-focal_amd64.deb' + 'containerd.io_2.2.1-1~ubuntu.24.04~noble_amd64.deb' + 'docker-ce_29.2.1-1~ubuntu.24.04~noble_amd64.deb' + 'docker-ce-cli_29.2.1-1~ubuntu.24.04~noble_amd64.deb' ) declare packages_csv packages_csv="$(printf ',%s' "${PACKAGES[@]}")" packages_csv="${packages_csv:1}" -curl --remote-name-all --fail "https://download.docker.com/linux/ubuntu/dists/focal/pool/stable/amd64/{${packages_csv}}" +curl --remote-name-all --fail --location --retry 5 --retry-delay 5 --connect-timeout 10 --max-time 120 "https://download.docker.com/linux/ubuntu/dists/noble/pool/stable/amd64/{${packages_csv}}" log_for_sentry "Installing Docker" dpkg --install "${PACKAGES[@]}" rm "${PACKAGES[@]}" diff --git a/server_manager/messages/en-GB.json b/server_manager/messages/en-GB.json index 816f17ccd7..25ab80cdbe 100644 --- a/server_manager/messages/en-GB.json +++ b/server_manager/messages/en-GB.json @@ -123,35 +123,50 @@ "gcp-type-outline-server": "Type 'outline-server' in the 'Name' field.", "geo-amsterdam": "Amsterdam", "geo-bangalore": "Bangalore", + "geo-berlin": "Berlin", "geo-changhua-county": "Changhua County", + "geo-columbus": "Columbus", + "geo-dallas": "Dallas", + "geo-dammam": "Dammam", "geo-delhi": "Delhi", + "geo-doha": "Doha", "geo-eemshaven": "Eemshaven", "geo-frankfurt": "Frankfurt", "geo-hamina": "Hamina", "geo-hk": "Hong Kong", "geo-iowa": "Iowa", "geo-jakarta": "Jakarta", + "geo-johannesburg": "Johannesburg", "geo-jurong-west": "Jurong West", + "geo-kuala-lumpur": "Kuala Lumpur", "geo-las-vegas": "Las Vegas", "geo-london": "London", "geo-los-angeles": "Los Angeles", + "geo-madrid": "Madrid", "geo-melbourne": "Melbourne", + "geo-milan": "Milan", "geo-montreal": "Montréal", "geo-mumbai": "Mumbai", "geo-new-york-city": "New York", "geo-northern-virginia": "Northern Virginia", "geo-oregon": "Oregon", "geo-osaka": "Osaka", + "geo-paris": "Paris", + "geo-queretaro": "Querétaro", "geo-salt-lake-city": "Salt Lake City", "geo-san-francisco": "San Francisco", + "geo-santiago": "Santiago", "geo-sao-paulo": "São Paulo", "geo-seoul": "Seoul", "geo-sg": "Singapore", "geo-south-carolina": "South Carolina", "geo-st-ghislain": "St Ghislain", + "geo-stockholm": "Stockholm", "geo-sydney": "Sydney", + "geo-tel-aviv": "Tel Aviv", "geo-tokyo": "Tokyo", "geo-toronto": "Toronto", + "geo-turin": "Turin", "geo-warsaw": "Warsaw", "geo-zurich": "Zürich", "key": "Key {keyId}", diff --git a/server_manager/messages/en.json b/server_manager/messages/en.json index e7b71cdb15..295e040992 100644 --- a/server_manager/messages/en.json +++ b/server_manager/messages/en.json @@ -123,35 +123,50 @@ "gcp-type-outline-server": "Type 'outline-server' in the 'Name' field.", "geo-amsterdam": "Amsterdam", "geo-bangalore": "Bangalore", + "geo-berlin": "Berlin", "geo-changhua-county": "Changhua County", + "geo-columbus": "Columbus", + "geo-dallas": "Dallas", + "geo-dammam": "Dammam", "geo-delhi": "Delhi", + "geo-doha": "Doha", "geo-eemshaven": "Eemshaven", "geo-frankfurt": "Frankfurt", "geo-hamina": "Hamina", "geo-hk": "Hong Kong", "geo-iowa": "Iowa", "geo-jakarta": "Jakarta", + "geo-johannesburg": "Johannesburg", "geo-jurong-west": "Jurong West", + "geo-kuala-lumpur": "Kuala Lumpur", "geo-las-vegas": "Las Vegas", "geo-london": "London", "geo-los-angeles": "Los Angeles", + "geo-madrid": "Madrid", "geo-melbourne": "Melbourne", + "geo-milan": "Milan", "geo-montreal": "Montréal", "geo-mumbai": "Mumbai", "geo-new-york-city": "New York", "geo-northern-virginia": "Northern Virginia", "geo-oregon": "Oregon", "geo-osaka": "Osaka", + "geo-paris": "Paris", + "geo-queretaro": "Querétaro", "geo-salt-lake-city": "Salt Lake City", "geo-san-francisco": "San Francisco", + "geo-santiago": "Santiago", "geo-sao-paulo": "São Paulo", "geo-seoul": "Seoul", "geo-sg": "Singapore", "geo-south-carolina": "South Carolina", "geo-st-ghislain": "St. Ghislain", + "geo-stockholm": "Stockholm", "geo-sydney": "Sydney", + "geo-tel-aviv": "Tel Aviv", "geo-tokyo": "Tokyo", "geo-toronto": "Toronto", + "geo-turin": "Turin", "geo-warsaw": "Warsaw", "geo-zurich": "Zürich", "key": "Key {keyId}", diff --git a/server_manager/messages/master_messages.json b/server_manager/messages/master_messages.json index 61fcdffa12..56e3edd2fe 100644 --- a/server_manager/messages/master_messages.json +++ b/server_manager/messages/master_messages.json @@ -144,14 +144,34 @@ "message": "Bangalore", "description": "Name of the city in India." }, + "geo_berlin": { + "message": "Berlin", + "description": "Name of the city in Germany." + }, "geo_changhua_county": { "message": "Changhua County", "description": "Name of the county in Taiwan." }, + "geo_columbus": { + "message": "Columbus", + "description": "Name of the city in Ohio, USA." + }, + "geo_dallas": { + "message": "Dallas", + "description": "Name of the city in Texas, USA." + }, + "geo_dammam": { + "message": "Dammam", + "description": "Name of the city in Saudi Arabia." + }, "geo_delhi": { "message": "Delhi", "description": "Name of the city in India." }, + "geo_doha": { + "message": "Doha", + "description": "Name of the city in Qatar." + }, "geo_eemshaven": { "message": "Eemshaven", "description": "Name of the seaport in the Netherlands." @@ -176,10 +196,18 @@ "message": "Jakarta", "description": "Name of the capital of Indonesia." }, + "geo_johannesburg": { + "message": "Johannesburg", + "description": "Name of the city in South Africa." + }, "geo_jurong_west": { "message": "Jurong West", "description": "Name of the area in Singapore." }, + "geo_kuala_lumpur": { + "message": "Kuala Lumpur", + "description": "Name of the capital city of Malaysia." + }, "geo_las_vegas": { "message": "Las Vegas", "description": "Name of the city in the Nevada, USA." @@ -192,10 +220,18 @@ "message": "Los Angeles", "description": "Name of the city in California, USA." }, + "geo_madrid": { + "message": "Madrid", + "description": "Name of the city in Spain." + }, "geo_melbourne": { "message": "Melbourne", "description": "Name of the city in Australia." }, + "geo_milan": { + "message": "Milan", + "description": "Name of the city in Italy." + }, "geo_montreal": { "message": "Montréal", "description": "Name of the city in Canada." @@ -220,6 +256,14 @@ "message": "Osaka", "description": "Name of the city in Japan." }, + "geo_paris": { + "message": "Paris", + "description": "Name of the city in France." + }, + "geo_queretaro": { + "message": "Querétaro", + "description": "Name of the city in Mexico." + }, "geo_salt_lake_city": { "message": "Salt Lake City", "description": "Name of the city in Utah, USA." @@ -228,6 +272,10 @@ "message": "San Francisco", "description": "Name of the city in the United States." }, + "geo_santiago": { + "message": "Santiago", + "description": "Name of the city in Chile." + }, "geo_sao_paulo": { "message": "São Paulo", "description": "Name of the state in Brazil." @@ -248,10 +296,18 @@ "message": "St. Ghislain", "description": "Name of the town in Belgium." }, + "geo_stockholm": { + "message": "Stockholm", + "description": "Name of the city in Sweden." + }, "geo_sydney": { "message": "Sydney", "description": "Name of the city in Australia." }, + "geo_tel_aviv": { + "message": "Tel Aviv", + "description": "Name of the city in Israel." + }, "geo_tokyo": { "message": "Tokyo", "description": "Name of the city in Japan." @@ -260,6 +316,10 @@ "message": "Toronto", "description": "Name of the city in Canada." }, + "geo_turin": { + "message": "Turin", + "description": "Name of the city in Italy." + }, "geo_warsaw": { "message": "Warsaw", "description": "Name of the city in Poland." @@ -823,7 +883,6 @@ "message": "These steps will help you install Outline on a Linux server.", "description": "This string appears in the server setup view as a header when no specific cloud provider is selected. Lets the user know that the following sections provide instructions on how to install Outline on their server." }, - "manual_server_firewall": { "message": "Configure your firewall", "description": "This string appears in the server setup view as the header of a section that provides instructions to configure the server's firewall." diff --git a/server_manager/model/gcp.spec.ts b/server_manager/model/gcp.spec.ts new file mode 100644 index 0000000000..0568d06664 --- /dev/null +++ b/server_manager/model/gcp.spec.ts @@ -0,0 +1,98 @@ +// Copyright 2021 The Outline Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {Zone} from './gcp'; +import * as location from './location'; + +describe('Zone', () => { + const knownRegions = [ + 'africa-south1', + 'asia-east1', + 'asia-east2', + 'asia-northeast1', + 'asia-northeast2', + 'asia-northeast3', + 'asia-south1', + 'asia-south2', + 'asia-southeast1', + 'asia-southeast2', + 'asia-southeast3', + 'australia-southeast1', + 'australia-southeast2', + 'europe-central2', + 'europe-north1', + 'europe-north2', + 'europe-southwest1', + 'europe-west1', + 'europe-west2', + 'europe-west3', + 'europe-west4', + 'europe-west6', + 'europe-west8', + 'europe-west9', + 'europe-west10', + 'europe-west12', + 'me-central1', + 'me-central2', + 'me-west1', + 'northamerica-northeast1', + 'northamerica-northeast2', + 'northamerica-south1', + 'southamerica-east1', + 'southamerica-west1', + 'us-central1', + 'us-east1', + 'us-east4', + 'us-east5', + 'us-south1', + 'us-west1', + 'us-west2', + 'us-west3', + 'us-west4', + ]; + + it('extracts the region from the zone id', () => { + expect(new Zone('africa-south1-b').regionId).toEqual('africa-south1'); + }); + + it('maps all known regions to a location', () => { + knownRegions.forEach(regionId => { + expect(new Zone(`${regionId}-a`).location).toBeTruthy(); + }); + }); + + it('maps africa-south1 to Johannesburg', () => { + expect(new Zone('africa-south1-a').location).toBe(location.JOHANNESBURG); + }); + + it('maps asia-southeast3 to Kuala Lumpur', () => { + expect(new Zone('asia-southeast3-b').location).toBe(location.KUALA_LUMPUR); + }); + + it('maps europe-west10 to Berlin', () => { + expect(new Zone('europe-west10-a').location).toBe(location.BERLIN); + }); + + it('maps me-central1 to Doha', () => { + expect(new Zone('me-central1-a').location).toBe(location.DOHA); + }); + + it('maps northamerica-south1 to Queretaro', () => { + expect(new Zone('northamerica-south1-a').location).toBe(location.QUERETARO); + }); + + it('maps us-east5 to Columbus', () => { + expect(new Zone('us-east5-a').location).toBe(location.COLUMBUS); + }); +}); diff --git a/server_manager/model/gcp.ts b/server_manager/model/gcp.ts index 791cf42502..5f0a4d38b3 100644 --- a/server_manager/model/gcp.ts +++ b/server_manager/model/gcp.ts @@ -20,6 +20,7 @@ export class Zone implements location.CloudLocation { private static readonly LOCATION_MAP: { readonly [regionId: string]: location.GeoLocation; } = { + 'africa-south1': location.JOHANNESBURG, 'asia-east1': location.CHANGHUA_COUNTY, 'asia-east2': location.HONG_KONG, 'asia-northeast1': location.TOKYO, @@ -29,21 +30,35 @@ export class Zone implements location.CloudLocation { 'asia-south2': location.DELHI, 'asia-southeast1': location.JURONG_WEST, 'asia-southeast2': location.JAKARTA, + 'asia-southeast3': location.KUALA_LUMPUR, 'australia-southeast1': location.SYDNEY, 'australia-southeast2': location.MELBOURNE, + 'europe-central2': location.WARSAW, 'europe-north1': location.HAMINA, + 'europe-north2': location.STOCKHOLM, + 'europe-southwest1': location.MADRID, 'europe-west1': location.ST_GHISLAIN, + 'europe-west10': location.BERLIN, + 'europe-west12': location.TURIN, 'europe-west2': location.LONDON, 'europe-west3': location.FRANKFURT, 'europe-west4': location.EEMSHAVEN, 'europe-west6': location.ZURICH, - 'europe-central2': location.WARSAW, + 'europe-west8': location.MILAN, + 'europe-west9': location.PARIS, + 'me-central1': location.DOHA, + 'me-central2': location.DAMMAM, + 'me-west1': location.TEL_AVIV, 'northamerica-northeast1': location.MONTREAL, 'northamerica-northeast2': location.TORONTO, + 'northamerica-south1': location.QUERETARO, 'southamerica-east1': location.SAO_PAULO, + 'southamerica-west1': location.SANTIAGO, 'us-central1': location.IOWA, 'us-east1': location.SOUTH_CAROLINA, 'us-east4': location.NORTHERN_VIRGINIA, + 'us-east5': location.COLUMBUS, + 'us-south1': location.DALLAS, 'us-west1': location.OREGON, 'us-west2': location.LOS_ANGELES, 'us-west3': location.SALT_LAKE_CITY, diff --git a/server_manager/model/location.ts b/server_manager/model/location.ts index 21c70c275a..58d9d4d6fc 100644 --- a/server_manager/model/location.ts +++ b/server_manager/model/location.ts @@ -51,6 +51,8 @@ export const HAMINA = new GeoLocation('hamina', 'FI'); export const HONG_KONG = new GeoLocation('HK', 'HK'); export const JAKARTA = new GeoLocation('jakarta', 'ID'); export const JURONG_WEST = new GeoLocation('jurong-west', 'SG'); +export const JOHANNESBURG = new GeoLocation('johannesburg', 'ZA'); +export const KUALA_LUMPUR = new GeoLocation('kuala-lumpur', 'MY'); export const LAS_VEGAS = new GeoLocation('las-vegas', 'US'); export const LONDON = new GeoLocation('london', 'GB'); export const LOS_ANGELES = new GeoLocation('los-angeles', 'US'); @@ -62,7 +64,20 @@ export const NEW_YORK_CITY = new GeoLocation('new-york-city', 'US'); export const SAN_FRANCISCO = new GeoLocation('san-francisco', 'US'); export const SINGAPORE = new GeoLocation('SG', 'SG'); export const OSAKA = new GeoLocation('osaka', 'JP'); +export const STOCKHOLM = new GeoLocation('stockholm', 'SE'); +export const MADRID = new GeoLocation('madrid', 'ES'); +export const MILAN = new GeoLocation('milan', 'IT'); +export const PARIS = new GeoLocation('paris', 'FR'); +export const BERLIN = new GeoLocation('berlin', 'DE'); +export const TURIN = new GeoLocation('turin', 'IT'); +export const DOHA = new GeoLocation('doha', 'QA'); +export const DAMMAM = new GeoLocation('dammam', 'SA'); +export const TEL_AVIV = new GeoLocation('tel-aviv', 'IL'); +export const QUERETARO = new GeoLocation('queretaro', 'MX'); export const SAO_PAULO = new GeoLocation('sao-paulo', 'BR'); +export const SANTIAGO = new GeoLocation('santiago', 'CL'); +export const COLUMBUS = new GeoLocation('columbus', 'US'); +export const DALLAS = new GeoLocation('dallas', 'US'); export const SALT_LAKE_CITY = new GeoLocation('salt-lake-city', 'US'); export const SEOUL = new GeoLocation('seoul', 'KR'); export const ST_GHISLAIN = new GeoLocation('st-ghislain', 'BE'); diff --git a/server_manager/www/app.ts b/server_manager/www/app.ts index 629a9d601f..5df6e6f182 100644 --- a/server_manager/www/app.ts +++ b/server_manager/www/app.ts @@ -19,7 +19,7 @@ import * as Sentry from '@sentry/electron/renderer'; import * as semver from 'semver'; import {DisplayDataAmount, displayDataAmountToBytes} from './data_formatting'; -import {filterOptions, getShortName} from './location_formatting'; +import {filterOptions, getShortName, sortOptions} from './location_formatting'; import {parseManualServerConfig} from './management_urls'; import type {AppRoot, ServerListEntry} from './ui_components/app-root'; import {FeedbackDetail} from './ui_components/outline-feedback-dialog'; @@ -849,7 +849,11 @@ export class App { const map = await this.digitalOceanRetry(() => { return this.digitalOceanAccount.listLocations(); }); - regionPicker.options = filterOptions(map); + regionPicker.options = sortOptions( + filterOptions(map), + this.appRoot.localize as (id: string) => string, + this.appRoot.language + ); } catch (e) { console.error(`Failed to get list of available regions: ${e}`); this.appRoot.showError(this.appRoot.localize('error-do-regions')); diff --git a/server_manager/www/digitalocean_account.ts b/server_manager/www/digitalocean_account.ts index 354f72450b..f90b508a3a 100644 --- a/server_manager/www/digitalocean_account.ts +++ b/server_manager/www/digitalocean_account.ts @@ -113,7 +113,7 @@ export class DigitalOceanAccount implements digitalocean.Account { const dropletSpec = { installCommand, size: MACHINE_SIZE, - image: 'docker-20-04', + image: 'docker-20-04', // As of 2026-02-22, this image runs Ubuntu 22.04 despite the name. tags: [SHADOWBOX_TAG], }; if (this.debugMode) { diff --git a/server_manager/www/gcp_account.ts b/server_manager/www/gcp_account.ts index 997f463c98..2eff11795e 100644 --- a/server_manager/www/gcp_account.ts +++ b/server_manager/www/gcp_account.ts @@ -272,7 +272,7 @@ export class GcpAccount implements gcp.Account { boot: true, initializeParams: { sourceImage: - 'projects/ubuntu-os-cloud/global/images/family/ubuntu-2204-lts', + 'projects/ubuntu-os-cloud/global/images/family/ubuntu-2404-lts-amd64', }, }, ], diff --git a/server_manager/www/location_formatting.spec.ts b/server_manager/www/location_formatting.spec.ts index 286c99589e..183dbeccfa 100644 --- a/server_manager/www/location_formatting.spec.ts +++ b/server_manager/www/location_formatting.spec.ts @@ -16,6 +16,7 @@ import { filterOptions, getShortName, localizeCountry, + sortOptions, } from './location_formatting'; import * as location from '../model/location'; @@ -47,6 +48,21 @@ describe('getShortName', () => { ).toEqual('fake-id'); }); + it('formats location when translation is missing', () => { + expect( + getShortName( + { + id: 'fake-id', + location: new location.GeoLocation('kuala-lumpur', 'MY'), + }, + msgId => { + expect(msgId).toEqual('geo-kuala-lumpur'); + return msgId; + } + ) + ).toEqual('Kuala Lumpur'); + }); + it('returns empty string when the location is null', () => { expect( getShortName(null, _msgId => { @@ -162,3 +178,59 @@ describe('filterOptions', () => { expect(filtered).toEqual([available]); }); }); + +describe('sortOptions', () => { + const localize = (msgId: string) => { + const map: {[msgId: string]: string} = { + 'geo-montreal': 'Montreal', + 'geo-salt-lake-city': 'Salt Lake City', + 'geo-seoul': 'Seoul', + }; + return map[msgId] ?? msgId; + }; + + it('sorts by availability, location, country, and city', () => { + const options: location.CloudLocationOption[] = [ + { + cloudLocation: {id: 'unknown', location: null}, + available: true, + }, + { + cloudLocation: {id: 'seoul-zone', location: location.SEOUL}, + available: false, + }, + { + cloudLocation: {id: 'slc-zone', location: location.SALT_LAKE_CITY}, + available: true, + }, + { + cloudLocation: {id: 'montreal-zone', location: location.MONTREAL}, + available: true, + }, + ]; + + const sorted = sortOptions(options, localize, 'en'); + expect(sorted.map(option => option.cloudLocation.id)).toEqual([ + 'montreal-zone', + 'slc-zone', + 'unknown', + 'seoul-zone', + ]); + }); + + it('uses ID to break complete ties', () => { + const options = [ + { + cloudLocation: {id: 'z2', location: location.SEOUL}, + available: true, + }, + { + cloudLocation: {id: 'z1', location: location.SEOUL}, + available: true, + }, + ]; + + const sorted = sortOptions(options, localize, 'en'); + expect(sorted.map(option => option.cloudLocation.id)).toEqual(['z1', 'z2']); + }); +}); diff --git a/server_manager/www/location_formatting.ts b/server_manager/www/location_formatting.ts index cd52b81360..33a25d88e8 100644 --- a/server_manager/www/location_formatting.ts +++ b/server_manager/www/location_formatting.ts @@ -18,6 +18,21 @@ import { GeoLocation, } from '../model/location'; +function formatGeoId(geoId: string): string { + return geoId + .split('-') + .map(part => { + if (!part) { + return ''; + } + if (part === part.toUpperCase()) { + return part; + } + return `${part[0].toUpperCase()}${part.slice(1)}`; + }) + .join(' '); +} + /** * Returns the localized place name, or the data center ID if the location is * unknown. @@ -32,7 +47,12 @@ export function getShortName( if (!cloudLocation.location) { return cloudLocation.id; } - return localize(`geo-${cloudLocation.location.id.toLowerCase()}`); + const msgId = `geo-${cloudLocation.location.id.toLowerCase()}`; + const localized = localize(msgId); + if (localized && localized !== msgId) { + return localized; + } + return formatGeoId(cloudLocation.location.id); } /** @@ -54,6 +74,76 @@ export function localizeCountry( return displayName.of(geoLocation.countryCode); } +type SortKey = { + available: boolean; + hasKnownLocation: boolean; + country: string; + shortName: string; + id: string; +}; + +function getSortKey( + option: T, + localize: (id: string) => string, + language: string +): SortKey { + const geoLocation = option.cloudLocation.location; + let country = ''; + if (geoLocation) { + try { + country = localizeCountry(geoLocation, language); + } catch { + country = geoLocation.countryCode; + } + } + + return { + available: option.available, + hasKnownLocation: !!geoLocation, + country, + shortName: getShortName(option.cloudLocation, localize), + id: option.cloudLocation.id, + }; +} + +/** + * Returns options sorted for display: + * - available locations first + * - known locations before unknown fallback IDs + * - then by localized country and city names + */ +export function sortOptions( + options: readonly T[], + localize: (id: string) => string, + language: string +): T[] { + const collator = new Intl.Collator([language], { + sensitivity: 'base', + numeric: true, + }); + + return [...options] + .map(option => ({option, key: getSortKey(option, localize, language)})) + .sort((a, b) => { + if (a.key.available !== b.key.available) { + return a.key.available ? -1 : 1; + } + if (a.key.hasKnownLocation !== b.key.hasKnownLocation) { + return a.key.hasKnownLocation ? -1 : 1; + } + const countryCmp = collator.compare(a.key.country, b.key.country); + if (countryCmp !== 0) { + return countryCmp; + } + const shortNameCmp = collator.compare(a.key.shortName, b.key.shortName); + if (shortNameCmp !== 0) { + return shortNameCmp; + } + return collator.compare(a.key.id, b.key.id); + }) + .map(({option}) => option); +} + /** * Given an array of cloud location options, this function returns an array * containing one representative option for each GeoLocation. Available diff --git a/server_manager/www/outline-gcp-create-server-app.ts b/server_manager/www/outline-gcp-create-server-app.ts index 62a5919be2..15bfbb18cc 100644 --- a/server_manager/www/outline-gcp-create-server-app.ts +++ b/server_manager/www/outline-gcp-create-server-app.ts @@ -25,7 +25,7 @@ import {customElement, property, state} from 'lit/decorators.js'; import {unsafeHTML} from 'lit/directives/unsafe-html.js'; import {GcpAccount, isInFreeTier} from './gcp_account'; -import {filterOptions, getShortName} from './location_formatting'; +import {filterOptions, getShortName, sortOptions} from './location_formatting'; import {AppRoot} from './ui_components/app-root'; import {COMMON_STYLES} from './ui_components/cloud-install-styles'; import {OutlineRegionPicker} from './ui_components/outline-region-picker-step'; @@ -429,10 +429,14 @@ export class GcpCreateServerApp extends LitElement { this.regionPicker = this.shadowRoot.querySelector( '#regionPicker' ) as OutlineRegionPicker; - this.regionPicker.options = filterOptions(zoneOptions).map(option => ({ - markedBestValue: isInFreeTier(option.cloudLocation), - ...option, - })); + this.regionPicker.options = sortOptions( + filterOptions(zoneOptions).map(option => ({ + markedBestValue: isInFreeTier(option.cloudLocation), + ...option, + })), + this.localize, + this.language + ); } private onProjectIdChanged(event: CustomEvent) {