diff --git a/core/frontend/src/components/app/SettingsMenu.vue b/core/frontend/src/components/app/SettingsMenu.vue index e9a96414e4..7a66f53798 100644 --- a/core/frontend/src/components/app/SettingsMenu.vue +++ b/core/frontend/src/components/app/SettingsMenu.vue @@ -14,11 +14,12 @@ - +
-
- +
+ {{ operation_error }} @@ -39,99 +40,116 @@ Settings - - - - mdi-cog-refresh - - Reset Settings - - + + + + + + mdi-cog-refresh + + Reset Settings + + - + - - System Log Files ({{ log_folder_size }}) - + + System Log Files ({{ log_folder_size }}) + - - - - mdi-folder-download - - Download - + + + + mdi-folder-download + + Download + - - - mdi-trash-can - - Remove - - + + + mdi-trash-can + + Remove + + - + - - MAVLink Log Files ({{ mavlink_log_folder_size }}) - + + MAVLink Log Files ({{ mavlink_log_folder_size }}) + - - - - mdi-folder-download - - Download - + + + + mdi-folder-download + + Download + - - - mdi-trash-can - - Remove - - + + + mdi-trash-can + + Remove + + - + - - Run Vehicle Configuration Wizard - + + Run Vehicle Configuration Wizard + - - - - mdi-wizard-hat - - Enable - - + + + + mdi-wizard-hat + + Enable + + + + + + Service Management + + + Enable or disable BlueOS services. Hover over the info icon for details about each service. + + + + + + + + @@ -153,6 +171,7 @@ import Vue from 'vue' import SpinningLogo from '@/components/common/SpinningLogo.vue' +import ServiceDisabler from '@/components/settings/serviceDisabler.vue' import filebrowser from '@/libs/filebrowser' import Notifier from '@/libs/notifier' import bag from '@/store/bag' @@ -168,6 +187,7 @@ export default Vue.extend({ name: 'SettingsMenu', components: { SpinningLogo, + ServiceDisabler, }, data() { return { diff --git a/core/frontend/src/components/settings/serviceDisabler.vue b/core/frontend/src/components/settings/serviceDisabler.vue new file mode 100644 index 0000000000..bf742e21dc --- /dev/null +++ b/core/frontend/src/components/settings/serviceDisabler.vue @@ -0,0 +1,248 @@ + + + + + diff --git a/core/frontend/src/store/commander.ts b/core/frontend/src/store/commander.ts index 66ea4bce6d..b07078d31f 100644 --- a/core/frontend/src/store/commander.ts +++ b/core/frontend/src/store/commander.ts @@ -2,6 +2,7 @@ import Notifier from '@/libs/notifier' import { ReturnStruct, ShutdownType } from '@/types/commander' import { commander_service } from '@/types/frontend_services' import back_axios, { isBackendOffline } from '@/utils/api' +import axios from 'axios' const notifier = new Notifier(commander_service) @@ -169,6 +170,26 @@ class CommanderStore { }) } + async setEnvironmentVariables(variables: Record): Promise { + return back_axios({ + method: 'post', + url: `/version-chooser/v1.0/version/environment_variables`, + timeout: 5000, + data: variables, + }) + .then(() => { + // Update cache + this.environmentVariables = variables + }) + .catch((error) => { + if (isBackendOffline(error)) { + return + } + const message = `Could not set environment variables: ${error.response?.data ?? error.message}.` + notifier.pushError('COMMANDER_SET_ENV_VARS_FAIL', message, true) + }) + } + async getRaspiEEPROM(): Promise { return back_axios({ method: 'get', @@ -208,6 +229,26 @@ class CommanderStore { return undefined }) } + + async killService(service: string): Promise { + await axios.post(`${this.API_URL}/services/kill`, null, { + params: { + service_name: service, + i_know_what_i_am_doing: true, + }, + timeout: 10000, + }) + } + + async restartService(service: string): Promise { + await axios.post(`${this.API_URL}/services/restart`, null, { + params: { + service_name: service, + i_know_what_i_am_doing: true, + }, + timeout: 10000, + }) + } } const Commander = CommanderStore.getInstance() diff --git a/core/frontend/src/utils/version_chooser.ts b/core/frontend/src/utils/version_chooser.ts index eed0dc2cc4..60d84be3a4 100644 --- a/core/frontend/src/utils/version_chooser.ts +++ b/core/frontend/src/utils/version_chooser.ts @@ -6,7 +6,7 @@ import { DockerLoginInfo, LocalVersionsQuery, Version, VersionsQuery, VersionType, } from '@/types/version-chooser' -import back_axios from '@/utils/api' +import back_axios, { isBackendOffline } from '@/utils/api' const API_URL = '/version-chooser/v1.0' const DEFAULT_REMOTE_IMAGE = 'bluerobotics/blueos-core' @@ -199,6 +199,43 @@ async function dockerAccounts(): Promise { return data.data as DockerLoginInfo[] } + +async function getVersionChooserEnvironmentVariables(): Promise | undefined> { + return back_axios({ + method: 'get', + url: '/version-chooser/v1.0/version/environment_variables', + timeout: 5000, + }) + .then((response) => response.data.environment_variables) + .catch((error) => { + if (isBackendOffline(error)) { + return undefined + } + const message = `Could not get version chooser environment variables: ${error.response?.data ?? error.message}.` + notifier.pushError('COMMANDER_GET_VERSION_CHOOSER_ENV_VARS_FAIL', message, true) + return undefined + }) +} + +async function setVersionChooserEnvironmentVariables(variables: Record): Promise { + return back_axios({ + method: 'post', + url: '/version-chooser/v1.0/version/environment_variables', + timeout: 5000, + data: { + environment_variables: variables, + }, + }) + .then(() => {}) + .catch((error) => { + if (isBackendOffline(error)) { + return + } + const message = `Could not set version chooser environment variables: ${error.response?.data ?? error.message}.` + notifier.pushError('COMMANDER_SET_VERSION_CHOOSER_ENV_VARS_FAIL', message, true) + }) +} + export { DEFAULT_REMOTE_IMAGE, dockerAccounts, @@ -209,6 +246,8 @@ export { getLatestStable, getLatestVersion, getVersionType, + getVersionChooserEnvironmentVariables, + setVersionChooserEnvironmentVariables, isSemVer, loadAvailableVersions, loadBootstrapCurrentVersion, diff --git a/core/services/commander/main.py b/core/services/commander/main.py index 3b3b9aadd7..643159d394 100755 --- a/core/services/commander/main.py +++ b/core/services/commander/main.py @@ -19,6 +19,8 @@ from fastapi_versioning import VersionedFastAPI, version from loguru import logger +from service_definitions import get_service + SERVICE_NAME = "commander" LOG_FOLDER_PATH = os.environ.get("BLUEOS_LOG_FOLDER_PATH", "/var/logs/blueos") MAVLINK_LOG_FOLDER_PATH = os.environ.get("BLUEOS_MAVLINK_LOG_FOLDER_PATH", "/shortcuts/ardupilot_logs/logs/") @@ -206,6 +208,52 @@ async def environment_variables() -> Any: return os.environ +@app.post("/services/kill", status_code=status.HTTP_200_OK) +@version(1, 0) +async def kill_service(service_name: str, i_know_what_i_am_doing: bool = False) -> Any: + check_what_i_am_doing(i_know_what_i_am_doing) + # The service name is the tmux session name + command = f"tmux kill-session -t {service_name}" + output = subprocess.run(command, shell=True, check=True, capture_output=True, text=True) + logger.debug(f"Kill service output: {output}") + message = { + "stdout": f"{output.stdout!r}", + "stderr": f"{output.stderr!r}", + "return_code": output.returncode, + } + return message + + +@app.post("/services/restart", status_code=status.HTTP_200_OK) +@version(1, 0) +async def restart_service(service_name: str, i_know_what_i_am_doing: bool = False) -> Any: + check_what_i_am_doing(i_know_what_i_am_doing) + + try: + service = get_service(service_name) + except KeyError as error: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unknown service: {service_name}", + ) from error + + # Create new tmux session and run the command + create_session = f"tmux new -d -s {service_name}" + output = subprocess.run(create_session, shell=True, check=True, capture_output=True, text=True) + + # Run the service with memory limit using run-service script + run_service_cmd = f'tmux send-keys -t {service_name}:0 \'run-service "{service_name}" "{service.command}" {service.memory_limit}\' C-m' + output = subprocess.run(run_service_cmd, shell=True, check=True, capture_output=True, text=True) + + logger.debug(f"Restart service output: {output}") + message = { + "stdout": f"{output.stdout!r}", + "stderr": f"{output.stderr!r}", + "return_code": output.returncode, + } + return message + + app = VersionedFastAPI(app, version="1.0.0", prefix_format="/v{major}.{minor}", enable_latest=True) diff --git a/core/services/commander/service_definitions.py b/core/services/commander/service_definitions.py new file mode 100644 index 0000000000..d246032c79 --- /dev/null +++ b/core/services/commander/service_definitions.py @@ -0,0 +1,92 @@ +"""Service definitions for BlueOS. + +This module contains the definitions of all BlueOS services, including their commands and memory limits. +It is used by both the startup script and the commander service to ensure consistency. +""" + +from dataclasses import dataclass +from typing import Dict, List + + +@dataclass +class Service: + """Represents a BlueOS service.""" + + command: str + memory_limit: int + priority: bool = False # True for high-priority services that should start first + + +# Define all services +SERVICES: Dict[str, Service] = { + "autopilot": Service( + command="nice --19 /home/pi/services/ardupilot_manager/main.py", memory_limit=0, priority=True + ), + "cable_guy": Service(command="/home/pi/services/cable_guy/main.py", memory_limit=0, priority=True), + "video": Service( + command="nice --19 mavlink-camera-manager --default-settings BlueROVUDP --mavlink tcpout:127.0.0.1:5777 " + "--mavlink-system-id 1 --gst-feature-rank omxh264enc=0,v4l2h264enc=250,x264enc=260 " + "--log-path /var/logs/blueos/services/mavlink-camera-manager --stun-server stun://stun.l.google.com:19302 --verbose", + memory_limit=0, + priority=True, + ), + "mavlink2rest": Service( + command="mavlink2rest --connect=udpout:127.0.0.1:14001 --server [::]:6040 --system-id 1 --component-id 194", + memory_limit=0, + priority=True, + ), + "kraken": Service(command="nice -19 /home/pi/services/kraken/main.py", memory_limit=0), + "wifi": Service(command="nice -19 /home/pi/services/wifi/main.py --socket wlan0", memory_limit=0), + "zenohd": Service( + command="ZENOH_BACKEND_FS_ROOT=/home/pi/tools/zenoh zenohd -c /home/pi/tools/zenoh/blueos-zenoh.json5", + memory_limit=0, + ), + "beacon": Service(command="/home/pi/services/beacon/main.py", memory_limit=250), + "bridget": Service(command="nice -19 sudo -u blueos /home/pi/services/bridget/main.py", memory_limit=0), + "commander": Service(command="/home/pi/services/commander/main.py", memory_limit=250), + "nmea_injector": Service( + command="nice -19 /home/pi/services/nmea_injector/nmea_injector/main.py", memory_limit=250 + ), + "helper": Service(command="/home/pi/services/helper/main.py", memory_limit=250), + "iperf3": Service(command="iperf3 --server --port 5201", memory_limit=250), + "linux2rest": Service( + command="linux2rest --log-path /var/logs/blueos/services/linux2rest " + "--log-settings netstat=30,platform=10,serial-ports=10,system-cpu=10,system-disk=30," + "system-info=10,system-memory=10,system-network=10,system-process=60,system-temperature=10," + "system-unix-time-seconds=10", + memory_limit=250, + ), + "filebrowser": Service( + command="nice -19 filebrowser --database /etc/filebrowser/filebrowser.db --baseurl /file-browser", + memory_limit=250, + ), + "versionchooser": Service(command="/home/pi/services/versionchooser/main.py", memory_limit=250), + "pardal": Service(command="nice -19 /home/pi/services/pardal/main.py", memory_limit=250), + "ping": Service(command="nice -19 sudo -u blueos /home/pi/services/ping/main.py", memory_limit=0), + "user_terminal": Service(command="cat /etc/motd", memory_limit=0), + "ttyd": Service( + command='nice -19 ttyd -p 8088 sh -c "/usr/bin/tmux attach -t user_terminal || /usr/bin/tmux new -s user_terminal"', + memory_limit=250, + ), + "nginx": Service(command="nice -18 nginx -g 'daemon off;' -c /home/pi/tools/nginx/nginx.conf", memory_limit=250), + "log_zipper": Service( + command="nice -20 /home/pi/services/log_zipper/main.py '/shortcuts/system_logs/\\*\\*/\\*.log' --max-age-minutes 60", + memory_limit=250, + ), + "bag_of_holding": Service(command="/home/pi/services/bag_of_holding/main.py", memory_limit=250), +} + + +def get_priority_services() -> List[str]: + """Get the list of priority services that should start first.""" + return [name for name, service in SERVICES.items() if service.priority] + + +def get_regular_services() -> List[str]: + """Get the list of regular (non-priority) services.""" + return [name for name, service in SERVICES.items() if not service.priority] + + +def get_service(name: str) -> Service: + """Get a service by name. Raises KeyError if service doesn't exist.""" + return SERVICES[name] diff --git a/core/services/versionchooser/main.py b/core/services/versionchooser/main.py index 4c571e2f48..a374ea25f5 100755 --- a/core/services/versionchooser/main.py +++ b/core/services/versionchooser/main.py @@ -96,6 +96,15 @@ async def docker_logout(request: web.Request) -> Any: return make_docker_logout(info) +async def set_environment_variables(request: web.Request) -> Any: + data = await request.json() + return await versionChooser.set_environment_variables(data) + + +async def get_environment_variables() -> Any: + return await versionChooser.get_environment_variables() + + def docker_accounts() -> Any: return get_docker_accounts() diff --git a/core/services/versionchooser/openapi/versionchooser.yaml b/core/services/versionchooser/openapi/versionchooser.yaml index b4194da361..5793997076 100644 --- a/core/services/versionchooser/openapi/versionchooser.yaml +++ b/core/services/versionchooser/openapi/versionchooser.yaml @@ -239,6 +239,41 @@ paths: items: $ref: '#/components/schemas/DockerLoginInfo' + /version/environment_variables: + get: + operationId: main.get_environment_variables + summary: Get the environment variables + responses: + '200': + description: Environment variables + content: + application/json: + schema: + type: object + properties: + environment_variables: + type: object + additionalProperties: + type: string + post: + operationId: main.set_environment_variables + summary: Set the environment variables + requestBody: + content: + application/json: + schema: + type: object + properties: + environment_variables: + type: object + additionalProperties: + type: string + responses: + '200': + description: Environment variables set + '400': + description: Invalid environment variables + components: schemas: Version: diff --git a/core/services/versionchooser/utils/chooser.py b/core/services/versionchooser/utils/chooser.py index f1899312c8..c10be8c305 100644 --- a/core/services/versionchooser/utils/chooser.py +++ b/core/services/versionchooser/utils/chooser.py @@ -386,3 +386,31 @@ async def restart(self) -> web.Response: core = await self.client.containers.get("blueos-core") # type: ignore await core.kill() return web.Response(status=200, text="Restarting...") + + async def get_environment_variables(self) -> web.Response: + with open(DOCKER_CONFIG_PATH, encoding="utf-8") as startup_file: + try: + core = json.load(startup_file)["core"] + environment_variables = core.get("environment_variables", {}) + return web.json_response(environment_variables) + except KeyError as error: + logger.warning(f"Invalid version file: {error}") + return web.Response(status=500, text=f"Invalid version file: {error}") + except Exception as e: + logger.warning(f"Unable to load settings file: {e}") + return web.Response(status=500, text=f"Unable to load settings file: {e}") + + async def set_environment_variables(self, environment_variables: Dict[str, Any]) -> web.Response: + with open(DOCKER_CONFIG_PATH, "r+", encoding="utf-8") as startup_file: + try: + data = json.load(startup_file) + if "core" not in data: + data["core"] = {} + data["core"]["environment_variables"] = environment_variables + startup_file.seek(0) + startup_file.write(json.dumps(data, indent=2)) + startup_file.truncate() + except Exception as e: + logger.warning(f"Unable to set environment variables: {e}") + return web.Response(status=500, text=f"Unable to set environment variables: {e}") + return web.Response(status=200, text="Environment variables set")