From 732296d179a4efa4327fc19ff428464204b445d0 Mon Sep 17 00:00:00 2001 From: Kenneth Brewer Date: Sat, 25 Apr 2026 03:41:02 -0400 Subject: [PATCH 1/4] feat: Updated the map to show the coordinates as the user moves the cursor over the map. Changed the cursor to a crosshairs to make it easier to place map markers. --- .../inertia/components/maps/MapComponent.tsx | 133 +++++++++++++++--- admin/inertia/pages/maps.tsx | 35 +++-- install/management_compose.yaml | 14 +- 3 files changed, 145 insertions(+), 37 deletions(-) diff --git a/admin/inertia/components/maps/MapComponent.tsx b/admin/inertia/components/maps/MapComponent.tsx index bac307d..1e1f00a 100644 --- a/admin/inertia/components/maps/MapComponent.tsx +++ b/admin/inertia/components/maps/MapComponent.tsx @@ -13,22 +13,34 @@ import { Protocol } from 'pmtiles' import { useEffect, useRef, useState, useCallback } from 'react' type ScaleUnit = 'imperial' | 'metric' + import { useMapMarkers, PIN_COLORS } from '~/hooks/useMapMarkers' import type { PinColorId } from '~/hooks/useMapMarkers' import MarkerPin from './MarkerPin' import MarkerPanel from './MarkerPanel' -export default function MapComponent() { +export default function MapComponent({ isHoveringUI, showCoordinatesEnabled, }: {isHoveringUI: boolean, showCoordinatesEnabled: boolean}) { const mapRef = useRef(null) const { markers, addMarker, deleteMarker } = useMapMarkers() + const [placingMarker, setPlacingMarker] = useState<{ lng: number; lat: number } | null>(null) const [markerName, setMarkerName] = useState('') const [markerColor, setMarkerColor] = useState('orange') const [selectedMarkerId, setSelectedMarkerId] = useState(null) + const [scaleUnit, setScaleUnit] = useState( () => (localStorage.getItem('nomad:map-scale-unit') as ScaleUnit) || 'metric' ) + const [cursorLngLat, setCursorLngLat] = useState<{ + lng: number + lat: number + x: number + y: number + } | null>(null) + + const [showCoordinates, setShowCoordinates] = useState(false) + const toggleScaleUnit = useCallback(() => { setScaleUnit((prev) => { const next = prev === 'metric' ? 'imperial' : 'metric' @@ -37,15 +49,45 @@ export default function MapComponent() { }) }, []) - // Add the PMTiles protocol to maplibre-gl useEffect(() => { - let protocol = new Protocol() + const protocol = new Protocol() maplibregl.addProtocol('pmtiles', protocol.tile) + return () => { maplibregl.removeProtocol('pmtiles') } }, []) + const hideCoordinates = useCallback(() => { + setShowCoordinates(false) + setCursorLngLat(null) + }, []) + + const handleMouseMove = useCallback((e: MapLayerMouseEvent) => { + const target = e.originalEvent.target as HTMLElement | null + + if (target?.closest('.maplibregl-control-container, .maplibregl-ctrl')) { + hideCoordinates() + return + } + + if (!showCoordinatesEnabled || + isHoveringUI || + target?.closest('.maplibregl-control-container, .maplibregl-ctrl') + ) { + hideCoordinates() + return + } + + setShowCoordinates(true) + setCursorLngLat({ + lng: e.lngLat.lng, + lat: e.lngLat.lat, + x: e.point.x, + y: e.point.y, + }) + }, [hideCoordinates, isHoveringUI]) + const handleMapClick = useCallback((e: MapLayerMouseEvent) => { setPlacingMarker({ lng: e.lngLat.lng, lat: e.lngLat.lat }) setMarkerName('') @@ -78,6 +120,21 @@ export default function MapComponent() { return ( +
{ + const target = e.target as HTMLElement | null + + if ( + target?.closest( + '.maplibregl-control-container, .maplibregl-ctrl, .maplibregl-ctrl-group, .maplibregl-ctrl-scale' + ) + ) { + hideCoordinates() + } + }} + > + + {showCoordinates && showCoordinates && cursorLngLat && ( +
+ {cursorLngLat.lat.toFixed(6)}, {cursorLngLat.lng.toFixed(6)} +
+ )} +
+
- {/* Existing markers */} {markers.map((marker) => ( ))} - {/* Popup for selected marker */} {selectedMarker && ( )} - {/* Popup for placing a new marker */} {placingMarker && ( setPlacingMarker(null)} closeOnClick={false} > -
+
+
{PIN_COLORS.map((c) => ( ))}
+
+
- {/* Marker panel overlay */} - +
+ +
) } diff --git a/admin/inertia/pages/maps.tsx b/admin/inertia/pages/maps.tsx index 9fe20e0..d8585b1 100644 --- a/admin/inertia/pages/maps.tsx +++ b/admin/inertia/pages/maps.tsx @@ -5,10 +5,13 @@ import StyledButton from '~/components/StyledButton' import { IconArrowLeft } from '@tabler/icons-react' import { FileEntry } from '../../types/files' import Alert from '~/components/Alert' +import { useState } from 'react' export default function Maps(props: { maps: { baseAssetsExist: boolean; regionFiles: FileEntry[] } }) { + const [isHoveringUI, setIsHoveringUI] = useState(false) + const [showMapCoordinates, setShowMapCoordinates] = useState(true) const alertMessage = !props.maps.baseAssetsExist ? 'The base map assets have not been installed. Please download them first to enable map functionality.' : props.maps.regionFiles.length === 0 @@ -20,19 +23,35 @@ export default function Maps(props: {
{/* Nav and alerts are overlayed */} -
+
setIsHoveringUI(true)} + onMouseLeave={() => setIsHoveringUI(false)} + >

Back to Home

- - - Manage Map Regions - - +
+ + + + + Manage Map Regions + + +
{alertMessage && ( -
+
setIsHoveringUI(true)} + onMouseLeave={() => setIsHoveringUI(false)} + > )}
- +
diff --git a/install/management_compose.yaml b/install/management_compose.yaml index f4c7b5f..1ddcb00 100644 --- a/install/management_compose.yaml +++ b/install/management_compose.yaml @@ -9,8 +9,8 @@ name: project-nomad services: admin: - image: ghcr.io/crosstalk-solutions/project-nomad:latest - pull_policy: always + image: project-nomad:local + pull_policy: never #ghcr.io/crosstalk-solutions/project-nomad:latest container_name: nomad_admin restart: unless-stopped extra_hosts: @@ -27,18 +27,18 @@ services: - PORT=8080 - LOG_LEVEL=info # APP_KEY needs to be at least 16 chars or will fail validation and container won't start! - - APP_KEY=replaceme + - APP_KEY=ce9579a56b32e05ee432be91e6d1d9b95cb3a00eb92789559569decdb76c4db2 # # Leave HOST as is so the admin server listens all interfaces within the container - this doesn't affect how you access it from the host, it's just for internal container networking - HOST=0.0.0.0 # URL should be set to the URL you will access the admin interface at (e.g. http://localhost:8080 or http://192.168.1.x:8080) - - URL=replaceme + - URL=http://nomad-dev.brewerhomestead.com/ - DB_HOST=mysql # If you change the MySQL port, make sure to update this accordingly - DB_PORT=3306 - DB_DATABASE=nomad - DB_USER=nomad_user # Needs to match the MYSQL_PASSWORD in the mysql service! - - DB_PASSWORD=replaceme + - DB_PASSWORD=2bc4dad18d5a0ecf5914428d91ebde27d42e8afafd72c73089a0dd1eba9ae1fc - DB_NAME=nomad - DB_SSL=false - REDIS_HOST=redis @@ -72,11 +72,11 @@ services: container_name: nomad_mysql restart: unless-stopped environment: - - MYSQL_ROOT_PASSWORD=replaceme + - MYSQL_ROOT_PASSWORD=d797ecbd5cee07758909a628cb0a2a75285dc3bac42d1513a8d2aed95c632577 - MYSQL_DATABASE=nomad - MYSQL_USER=nomad_user # Needs to match DB_PASSWORD in the admin service! - - MYSQL_PASSWORD=replaceme + - MYSQL_PASSWORD=2bc4dad18d5a0ecf5914428d91ebde27d42e8afafd72c73089a0dd1eba9ae1fc volumes: - /opt/project-nomad/mysql:/var/lib/mysql # Persist MySQL data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for the database to ensure your data isn't lost when the container is removed or updated. healthcheck: From 1d7c40c37b35ecfc52a8253fc7fb413bb6a915ee Mon Sep 17 00:00:00 2001 From: Kenneth Brewer Date: Sat, 25 Apr 2026 04:18:49 -0400 Subject: [PATCH 2/4] Moved the scale unit control to its own component file for easier maintenance. Enhanced the behavior of the coordinate display on the map to not display when over the on screen controls, and the navigation bar. Added a toggle to turn off the coordinate display if the user doesn't wish to see it. Intentionally left the coordinate display when over a map marker so that the coordinates of the map marker can be estimated. In the future I intend to add the coordinates of a map marker when the map marker is clicked so that behavior may change in the future. --- .../components/maps/CoordinateOverlay.tsx | 25 ++ .../inertia/components/maps/MapComponent.tsx | 422 +++++++++--------- .../components/maps/ScaleUnitToggle.tsx | 46 ++ admin/inertia/pages/maps.tsx | 62 ++- 4 files changed, 316 insertions(+), 239 deletions(-) create mode 100644 admin/inertia/components/maps/CoordinateOverlay.tsx create mode 100644 admin/inertia/components/maps/ScaleUnitToggle.tsx diff --git a/admin/inertia/components/maps/CoordinateOverlay.tsx b/admin/inertia/components/maps/CoordinateOverlay.tsx new file mode 100644 index 0000000..43166bb --- /dev/null +++ b/admin/inertia/components/maps/CoordinateOverlay.tsx @@ -0,0 +1,25 @@ +type CoordinateOverlayProps = { + latitude: number + longitude: number + x: number + y: number +} + +export default function CoordinateOverlay({ + latitude, + longitude, + x, + y, +}: CoordinateOverlayProps) { + return ( +
+ {latitude.toFixed(6)}, {longitude.toFixed(6)} +
+ ) +} diff --git a/admin/inertia/components/maps/MapComponent.tsx b/admin/inertia/components/maps/MapComponent.tsx index 1e1f00a..07fe1c6 100644 --- a/admin/inertia/components/maps/MapComponent.tsx +++ b/admin/inertia/components/maps/MapComponent.tsx @@ -9,20 +9,35 @@ import Map, { import type { MapRef, MapLayerMouseEvent } from 'react-map-gl/maplibre' import maplibregl from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' + import { Protocol } from 'pmtiles' import { useEffect, useRef, useState, useCallback } from 'react' -type ScaleUnit = 'imperial' | 'metric' - import { useMapMarkers, PIN_COLORS } from '~/hooks/useMapMarkers' import type { PinColorId } from '~/hooks/useMapMarkers' + import MarkerPin from './MarkerPin' import MarkerPanel from './MarkerPanel' +import CoordinateOverlay from './CoordinateOverlay' +import ScaleUnitToggle from './ScaleUnitToggle' -export default function MapComponent({ isHoveringUI, showCoordinatesEnabled, }: {isHoveringUI: boolean, showCoordinatesEnabled: boolean}) { +type ScaleUnit = 'imperial' | 'metric' + +type MapComponentProps = { + isHoveringUI: boolean + showCoordinatesEnabled: boolean +} + +export default function MapComponent({ + isHoveringUI, + showCoordinatesEnabled, +}: MapComponentProps) { const mapRef = useRef(null) + const animationFrameRef = useRef(null) + const { markers, addMarker, deleteMarker } = useMapMarkers() + const [isDraggingMap, setIsDraggingMap] = useState(false) const [placingMarker, setPlacingMarker] = useState<{ lng: number; lat: number } | null>(null) const [markerName, setMarkerName] = useState('') const [markerColor, setMarkerColor] = useState('orange') @@ -38,16 +53,8 @@ export default function MapComponent({ isHoveringUI, showCoordinatesEnabled, }: x: number y: number } | null>(null) - - const [showCoordinates, setShowCoordinates] = useState(false) - const toggleScaleUnit = useCallback(() => { - setScaleUnit((prev) => { - const next = prev === 'metric' ? 'imperial' : 'metric' - localStorage.setItem('nomad:map-scale-unit', next) - return next - }) - }, []) + const [showCoordinates, setShowCoordinates] = useState(false) useEffect(() => { const protocol = new Protocol() @@ -58,35 +65,54 @@ export default function MapComponent({ isHoveringUI, showCoordinatesEnabled, }: } }, []) + useEffect(() => { + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current) + } + } + }, []) + const hideCoordinates = useCallback(() => { setShowCoordinates(false) setCursorLngLat(null) }, []) - const handleMouseMove = useCallback((e: MapLayerMouseEvent) => { - const target = e.originalEvent.target as HTMLElement | null + const handleScaleUnitChange = useCallback((unit: ScaleUnit) => { + setScaleUnit(unit) + localStorage.setItem('nomad:map-scale-unit', unit) + }, []) - if (target?.closest('.maplibregl-control-container, .maplibregl-ctrl')) { - hideCoordinates() - return - } + const handleMouseMove = useCallback( + (e: MapLayerMouseEvent) => { + const target = e.originalEvent.target as HTMLElement | null - if (!showCoordinatesEnabled || - isHoveringUI || - target?.closest('.maplibregl-control-container, .maplibregl-ctrl') - ) { - hideCoordinates() - return - } + if ( + !showCoordinatesEnabled || + isHoveringUI || + isDraggingMap || + target?.closest('.maplibregl-control-container, .maplibregl-ctrl') + ) { + hideCoordinates() + return + } - setShowCoordinates(true) - setCursorLngLat({ - lng: e.lngLat.lng, - lat: e.lngLat.lat, - x: e.point.x, - y: e.point.y, - }) - }, [hideCoordinates, isHoveringUI]) + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current) + } + + animationFrameRef.current = requestAnimationFrame(() => { + setShowCoordinates(true) + setCursorLngLat({ + lng: e.lngLat.lng, + lat: e.lngLat.lat, + x: e.point.x, + y: e.point.y, + }) + }) + }, + [hideCoordinates, isHoveringUI, isDraggingMap, showCoordinatesEnabled] + ) const handleMapClick = useCallback((e: MapLayerMouseEvent) => { setPlacingMarker({ lng: e.lngLat.lng, lat: e.lngLat.lat }) @@ -120,203 +146,169 @@ export default function MapComponent({ isHoveringUI, showCoordinatesEnabled, }: return ( -
{ - const target = e.target as HTMLElement | null - - if ( - target?.closest( - '.maplibregl-control-container, .maplibregl-ctrl, .maplibregl-ctrl-group, .maplibregl-ctrl-scale' - ) - ) { +
{ + setIsDraggingMap(false) hideCoordinates() - } - }} - > - { + const target = e.target as HTMLElement | null + + if ( + target?.closest( + '.maplibregl-control-container, .maplibregl-ctrl, .maplibregl-ctrl-group, .maplibregl-ctrl-scale' + ) + ) { + hideCoordinates() + } }} - onClick={handleMapClick} - onMouseMove={handleMouseMove} - onMouseLeave={hideCoordinates} > - - - + { + setIsDraggingMap(true) + hideCoordinates() + }} + onMouseUp={() => { + setIsDraggingMap(false) + }} + onDragStart={() => { + setIsDraggingMap(true) + hideCoordinates() + }} + onDragEnd={() => { + setIsDraggingMap(false) + hideCoordinates() + }} + onClick={handleMapClick} + onMouseMove={handleMouseMove} + onMouseLeave={hideCoordinates} + > + + + - {showCoordinates && showCoordinates && cursorLngLat && ( -
- {cursorLngLat.lat.toFixed(6)}, {cursorLngLat.lng.toFixed(6)} -
- )} - -
-
- - - -
-
- - {markers.map((marker) => ( - { - e.originalEvent.stopPropagation() - setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id) - setPlacingMarker(null) - }} - > - c.id === marker.color)?.hex} - active={marker.id === selectedMarkerId} + {showCoordinates && cursorLngLat && ( + - - ))} + )} - {selectedMarker && ( - setSelectedMarkerId(null)} - closeOnClick={false} - > -
{selectedMarker.name}
-
- )} + - {placingMarker && ( - setPlacingMarker(null)} - closeOnClick={false} - > -
- setMarkerName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleSaveMarker() - if (e.key === 'Escape') setPlacingMarker(null) - }} - className="block w-full rounded border border-gray-300 px-2 py-1 text-sm placeholder:text-gray-400 focus:outline-none focus:border-gray-500" + {markers.map((marker) => ( + { + e.originalEvent.stopPropagation() + setSelectedMarkerId(marker.id === selectedMarkerId ? null : marker.id) + setPlacingMarker(null) + }} + > + c.id === marker.color)?.hex} + active={marker.id === selectedMarkerId} /> + + ))} -
- {PIN_COLORS.map((c) => ( + {selectedMarker && ( + setSelectedMarkerId(null)} + closeOnClick={false} + > +
{selectedMarker.name}
+
+ )} + + {placingMarker && ( + setPlacingMarker(null)} + closeOnClick={false} + > +
+ setMarkerName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveMarker() + if (e.key === 'Escape') setPlacingMarker(null) + }} + className="block w-full rounded border border-gray-300 px-2 py-1 text-sm placeholder:text-gray-400 focus:outline-none focus:border-gray-500" + /> + +
+ {PIN_COLORS.map((c) => ( + + ))} +
+ +
- ))} -
-
- - - + +
-
- - )} - + + )} +
diff --git a/admin/inertia/components/maps/ScaleUnitToggle.tsx b/admin/inertia/components/maps/ScaleUnitToggle.tsx new file mode 100644 index 0000000..ba0bceb --- /dev/null +++ b/admin/inertia/components/maps/ScaleUnitToggle.tsx @@ -0,0 +1,46 @@ +type ScaleUnit = 'imperial' | 'metric' + +type ScaleUnitToggleProps = { + scaleUnit: ScaleUnit + onChange: (unit: ScaleUnit) => void + onMouseEnter?: () => void +} + +export default function ScaleUnitToggle({ + scaleUnit, + onChange, + onMouseEnter, +}: ScaleUnitToggleProps) { + return ( +
+
+ + + +
+
+ ) +} diff --git a/admin/inertia/pages/maps.tsx b/admin/inertia/pages/maps.tsx index d8585b1..a1d8df1 100644 --- a/admin/inertia/pages/maps.tsx +++ b/admin/inertia/pages/maps.tsx @@ -1,29 +1,34 @@ -import MapsLayout from '~/layouts/MapsLayout' +import { useState } from 'react' import { Head, Link, router } from '@inertiajs/react' +import { IconArrowLeft } from '@tabler/icons-react' + +import MapsLayout from '~/layouts/MapsLayout' import MapComponent from '~/components/maps/MapComponent' import StyledButton from '~/components/StyledButton' -import { IconArrowLeft } from '@tabler/icons-react' -import { FileEntry } from '../../types/files' import Alert from '~/components/Alert' -import { useState } from 'react' + +import { FileEntry } from '../../types/files' export default function Maps(props: { maps: { baseAssetsExist: boolean; regionFiles: FileEntry[] } }) { const [isHoveringUI, setIsHoveringUI] = useState(false) const [showMapCoordinates, setShowMapCoordinates] = useState(true) + const alertMessage = !props.maps.baseAssetsExist ? 'The base map assets have not been installed. Please download them first to enable map functionality.' : props.maps.regionFiles.length === 0 - ? 'No map regions have been downloaded yet. Please download some regions to enable map functionality.' - : null + ? 'No map regions have been downloaded yet. Please download some regions to enable map functionality.' + : null return ( +
- {/* Nav and alerts are overlayed */} -
setIsHoveringUI(true)} onMouseLeave={() => setIsHoveringUI(false)} > @@ -31,24 +36,28 @@ export default function Maps(props: {

Back to Home

-
- - - - Manage Map Regions - - -
+
+ + + + + Manage Map Regions + + +
+ + {/* Alert */} {alertMessage && ( -
setIsHoveringUI(true)} onMouseLeave={() => setIsHoveringUI(false)} > @@ -66,8 +75,13 @@ export default function Maps(props: { />
)} + + {/* Map */}
- +
From 26949f72453bed27433a9c8c9b3258e40a500401 Mon Sep 17 00:00:00 2001 From: Kenneth Brewer Date: Sun, 26 Apr 2026 23:15:15 -0400 Subject: [PATCH 3/4] Remove local management compose file --- install/management_compose.yaml | 121 -------------------------------- 1 file changed, 121 deletions(-) delete mode 100644 install/management_compose.yaml diff --git a/install/management_compose.yaml b/install/management_compose.yaml deleted file mode 100644 index 1ddcb00..0000000 --- a/install/management_compose.yaml +++ /dev/null @@ -1,121 +0,0 @@ -# Project N.O.M.A.D. management services Docker Compose configuration -# -# This compose file defines the admin server, database, and other supporting services required to run Project N.O.M.A.D. -# You can use this with `docker-compose up -d` to start all the necessary services with a single command after installation. -# -# Note: we recommend leaving all of the environment variables as-is except for any "replaceme" values, -# which must be updated for the admin server to start successfully. The default values are optimized for ease of installation and use, -# but you can customize them as needed (e.g. changing ports, database connection details, log level, etc.) -name: project-nomad -services: - admin: - image: project-nomad:local - pull_policy: never #ghcr.io/crosstalk-solutions/project-nomad:latest - container_name: nomad_admin - restart: unless-stopped - extra_hosts: - - "host.docker.internal:host-gateway" # Enables host.docker.internal on Linux - ports: - - "8080:8080" - volumes: - - /opt/project-nomad/storage:/app/storage # If you change the default storage path (/opt/project-nomad/storage), make sure to update the disk-collector config as well! - - /var/run/docker.sock:/var/run/docker.sock # Allows the admin service to communicate with the Host's Docker daemon - - nomad-update-shared:/app/update-shared # Shared volume for update communication - environment: - - NODE_ENV=production - # PORT is the port the admin server listens on *inside* the container and should not be changed. If you want to change which port the admin interface is accessible from on the host, you can change the port mapping in the "ports" section (e.g. "9090:8080" to access it on port 9090 from the host) - - PORT=8080 - - LOG_LEVEL=info - # APP_KEY needs to be at least 16 chars or will fail validation and container won't start! - - APP_KEY=ce9579a56b32e05ee432be91e6d1d9b95cb3a00eb92789559569decdb76c4db2 - # # Leave HOST as is so the admin server listens all interfaces within the container - this doesn't affect how you access it from the host, it's just for internal container networking - - HOST=0.0.0.0 - # URL should be set to the URL you will access the admin interface at (e.g. http://localhost:8080 or http://192.168.1.x:8080) - - URL=http://nomad-dev.brewerhomestead.com/ - - DB_HOST=mysql - # If you change the MySQL port, make sure to update this accordingly - - DB_PORT=3306 - - DB_DATABASE=nomad - - DB_USER=nomad_user - # Needs to match the MYSQL_PASSWORD in the mysql service! - - DB_PASSWORD=2bc4dad18d5a0ecf5914428d91ebde27d42e8afafd72c73089a0dd1eba9ae1fc - - DB_NAME=nomad - - DB_SSL=false - - REDIS_HOST=redis - # If you change the Redis port, make sure to update this accordingly - - REDIS_PORT=6379 - - DISABLE_COMPRESSION=false # Most reverse proxies (Nginx, Caddy, etc.) will skip compression if the response is already compressed so this is usally a win all around, but if this causes issues with your setup you can set it to "true" to disable gzip in the admin server - depends_on: - mysql: - condition: service_healthy - redis: - condition: service_healthy - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/api/health"] - interval: 30s - timeout: 10s - retries: 3 - dozzle: - # Dozzle is an optional container that allows for easily viewing container logs. We recommend including it unless you have a specific reason not to. Note that if you don't install it, the "Service Logs & Metrics" link in Settings that launches Dozzle will not work. - image: amir20/dozzle:v10.0 - container_name: nomad_dozzle - restart: unless-stopped - ports: - - "9999:8080" - volumes: - - /var/run/docker.sock:/var/run/docker.sock # Allows Dozzle to read logs from the Host's Docker daemon - environment: - - DOZZLE_ENABLE_ACTIONS=false # Disabled — unauthenticated container stop/restart on LAN - - DOZZLE_ENABLE_SHELL=false # Disabled — shell access + Docker socket = privilege escalation - mysql: - image: mysql:8.0 - container_name: nomad_mysql - restart: unless-stopped - environment: - - MYSQL_ROOT_PASSWORD=d797ecbd5cee07758909a628cb0a2a75285dc3bac42d1513a8d2aed95c632577 - - MYSQL_DATABASE=nomad - - MYSQL_USER=nomad_user - # Needs to match DB_PASSWORD in the admin service! - - MYSQL_PASSWORD=2bc4dad18d5a0ecf5914428d91ebde27d42e8afafd72c73089a0dd1eba9ae1fc - volumes: - - /opt/project-nomad/mysql:/var/lib/mysql # Persist MySQL data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for the database to ensure your data isn't lost when the container is removed or updated. - healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] - interval: 30s - timeout: 10s - retries: 10 - redis: - image: redis:7-alpine - container_name: nomad_redis - restart: unless-stopped - volumes: - - /opt/project-nomad/redis:/data # Persist Redis data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for Redis to ensure your data isn't lost when the container is removed or updated. - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 30s - timeout: 10s - retries: 3 - updater: - # Updater is a lightweight sidecar container that allows the admin container to be updated from within it's own UI - image: ghcr.io/crosstalk-solutions/project-nomad-sidecar-updater:latest - pull_policy: always - container_name: nomad_updater - restart: unless-stopped - volumes: - - /var/run/docker.sock:/var/run/docker.sock # Allows communication with the Host's Docker daemon - - /opt/project-nomad:/opt/project-nomad # Writable access required so the updater can set the correct image tag in compose.yml. This needs to be the same location that the compose file is located at on the host for the updater to work correctly - - nomad-update-shared:/shared # Shared volume for communication with admin container - disk-collector: - # Disk Collector is a lightweight privileged container that collects disk usage information from the host system and shares it with the admin container so it can be displayed in the UI. - # It requires read-only access to the host filesystem and is designed to be as secure and limited in scope as possible while still providing the necessary functionality. - image: ghcr.io/crosstalk-solutions/project-nomad-disk-collector:latest - pull_policy: always - container_name: nomad_disk_collector - restart: unless-stopped - volumes: - - /:/host:ro,rslave # Read-only view of host FS with rslave propagation so /sys and /proc submounts are visible - - /opt/project-nomad/storage:/storage - -volumes: - nomad-update-shared: - driver: local From 9c4baaa343ea27576cd7a1183f5b91e93f483cd4 Mon Sep 17 00:00:00 2001 From: Kenneth Brewer Date: Tue, 28 Apr 2026 10:39:47 -0400 Subject: [PATCH 4/4] reverted management_compose.yaml --- install/management_compose.yaml | 121 ++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 install/management_compose.yaml diff --git a/install/management_compose.yaml b/install/management_compose.yaml new file mode 100644 index 0000000..f4c7b5f --- /dev/null +++ b/install/management_compose.yaml @@ -0,0 +1,121 @@ +# Project N.O.M.A.D. management services Docker Compose configuration +# +# This compose file defines the admin server, database, and other supporting services required to run Project N.O.M.A.D. +# You can use this with `docker-compose up -d` to start all the necessary services with a single command after installation. +# +# Note: we recommend leaving all of the environment variables as-is except for any "replaceme" values, +# which must be updated for the admin server to start successfully. The default values are optimized for ease of installation and use, +# but you can customize them as needed (e.g. changing ports, database connection details, log level, etc.) +name: project-nomad +services: + admin: + image: ghcr.io/crosstalk-solutions/project-nomad:latest + pull_policy: always + container_name: nomad_admin + restart: unless-stopped + extra_hosts: + - "host.docker.internal:host-gateway" # Enables host.docker.internal on Linux + ports: + - "8080:8080" + volumes: + - /opt/project-nomad/storage:/app/storage # If you change the default storage path (/opt/project-nomad/storage), make sure to update the disk-collector config as well! + - /var/run/docker.sock:/var/run/docker.sock # Allows the admin service to communicate with the Host's Docker daemon + - nomad-update-shared:/app/update-shared # Shared volume for update communication + environment: + - NODE_ENV=production + # PORT is the port the admin server listens on *inside* the container and should not be changed. If you want to change which port the admin interface is accessible from on the host, you can change the port mapping in the "ports" section (e.g. "9090:8080" to access it on port 9090 from the host) + - PORT=8080 + - LOG_LEVEL=info + # APP_KEY needs to be at least 16 chars or will fail validation and container won't start! + - APP_KEY=replaceme + # # Leave HOST as is so the admin server listens all interfaces within the container - this doesn't affect how you access it from the host, it's just for internal container networking + - HOST=0.0.0.0 + # URL should be set to the URL you will access the admin interface at (e.g. http://localhost:8080 or http://192.168.1.x:8080) + - URL=replaceme + - DB_HOST=mysql + # If you change the MySQL port, make sure to update this accordingly + - DB_PORT=3306 + - DB_DATABASE=nomad + - DB_USER=nomad_user + # Needs to match the MYSQL_PASSWORD in the mysql service! + - DB_PASSWORD=replaceme + - DB_NAME=nomad + - DB_SSL=false + - REDIS_HOST=redis + # If you change the Redis port, make sure to update this accordingly + - REDIS_PORT=6379 + - DISABLE_COMPRESSION=false # Most reverse proxies (Nginx, Caddy, etc.) will skip compression if the response is already compressed so this is usally a win all around, but if this causes issues with your setup you can set it to "true" to disable gzip in the admin server + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/api/health"] + interval: 30s + timeout: 10s + retries: 3 + dozzle: + # Dozzle is an optional container that allows for easily viewing container logs. We recommend including it unless you have a specific reason not to. Note that if you don't install it, the "Service Logs & Metrics" link in Settings that launches Dozzle will not work. + image: amir20/dozzle:v10.0 + container_name: nomad_dozzle + restart: unless-stopped + ports: + - "9999:8080" + volumes: + - /var/run/docker.sock:/var/run/docker.sock # Allows Dozzle to read logs from the Host's Docker daemon + environment: + - DOZZLE_ENABLE_ACTIONS=false # Disabled — unauthenticated container stop/restart on LAN + - DOZZLE_ENABLE_SHELL=false # Disabled — shell access + Docker socket = privilege escalation + mysql: + image: mysql:8.0 + container_name: nomad_mysql + restart: unless-stopped + environment: + - MYSQL_ROOT_PASSWORD=replaceme + - MYSQL_DATABASE=nomad + - MYSQL_USER=nomad_user + # Needs to match DB_PASSWORD in the admin service! + - MYSQL_PASSWORD=replaceme + volumes: + - /opt/project-nomad/mysql:/var/lib/mysql # Persist MySQL data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for the database to ensure your data isn't lost when the container is removed or updated. + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 30s + timeout: 10s + retries: 10 + redis: + image: redis:7-alpine + container_name: nomad_redis + restart: unless-stopped + volumes: + - /opt/project-nomad/redis:/data # Persist Redis data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for Redis to ensure your data isn't lost when the container is removed or updated. + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 3 + updater: + # Updater is a lightweight sidecar container that allows the admin container to be updated from within it's own UI + image: ghcr.io/crosstalk-solutions/project-nomad-sidecar-updater:latest + pull_policy: always + container_name: nomad_updater + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock # Allows communication with the Host's Docker daemon + - /opt/project-nomad:/opt/project-nomad # Writable access required so the updater can set the correct image tag in compose.yml. This needs to be the same location that the compose file is located at on the host for the updater to work correctly + - nomad-update-shared:/shared # Shared volume for communication with admin container + disk-collector: + # Disk Collector is a lightweight privileged container that collects disk usage information from the host system and shares it with the admin container so it can be displayed in the UI. + # It requires read-only access to the host filesystem and is designed to be as secure and limited in scope as possible while still providing the necessary functionality. + image: ghcr.io/crosstalk-solutions/project-nomad-disk-collector:latest + pull_policy: always + container_name: nomad_disk_collector + restart: unless-stopped + volumes: + - /:/host:ro,rslave # Read-only view of host FS with rslave propagation so /sys and /proc submounts are visible + - /opt/project-nomad/storage:/storage + +volumes: + nomad-update-shared: + driver: local