Merge pull request #877 from Freika/dev

0.24.1
This commit is contained in:
Evgenii Burmakin 2025-02-13 21:23:02 +01:00 committed by GitHub
commit 2ecd1b7763
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 804 additions and 57 deletions

View file

@ -1 +1 @@
0.24.0
0.24.1

View file

@ -4,7 +4,38 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
# 0.24.0 - 2025-02-09
# 0.24.1 - 2025-02-13
## Custom map tiles
In the user settings, you can now set a custom tile URL for the map. This is useful if you want to use a custom map tile provider or if you want to use a map tile provider that is not listed in the dropdown.
To set a custom tile URL, go to the user settings and set the `Maps` section to your liking. Be mindful that currently, only raster tiles are supported. The URL should be a valid tile URL, like `https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png`. You, as the user, are responsible for any extra costs that may occur due to using a custom tile URL.
### Added
- Safe settings for user with default values.
- Nominatim API is now supported as a reverse geocoding provider.
- In the user settings, you can now set a custom tile URL for the map. #429 #715
- In the user map settings, you can now see a chart of map tiles usage.
- If you have Prometheus exporter enabled, you can now see a `ruby_dawarich_map_tiles` metric in Prometheus, which shows the total number of map tiles loaded. Example:
```
# HELP ruby_dawarich_map_tiles_usage
# TYPE ruby_dawarich_map_tiles_usage counter
ruby_dawarich_map_tiles_usage 99
```
### Fixed
- Speed on the Points page is now being displayed in kilometers per hour. #700
- Fog of war displacement #774
### Reverted
- #748
# 0.24.0 - 2025-02-10
## Points speed units

View file

@ -1,2 +1,2 @@
prometheus_exporter: bundle exec prometheus_exporter -b ANY
web: bin/rails server -p 3000 -b ::
web: bin/rails server -p 3000 -b ::

View file

@ -28,6 +28,7 @@ Donate using crypto: [0x6bAd13667692632f1bF926cA9B421bEe7EaEB8D4](https://ethers
- Explore statistics like the number of countries and cities visited, total distance traveled, and more!
📄 **Changelog**: Find the latest updates [here](CHANGELOG.md).
👩‍💻 **Contribute**: See [CONTRIBUTING.md](CONTRIBUTING.md) for how to contribute to Dawarich.
---

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
class Api::V1::Maps::TileUsageController < ApiController
def create
Maps::TileUsage::Track.new(current_api_user.id, tile_usage_params[:count].to_i).call
head :ok
end
private
def tile_usage_params
params.require(:tile_usage).permit(:count)
end
end

View file

@ -0,0 +1,29 @@
# frozen_string_literal: true
class Settings::MapsController < ApplicationController
before_action :authenticate_user!
def index
@maps = current_user.safe_settings.maps
@tile_usage = 7.days.ago.to_date.upto(Time.zone.today).map do |date|
[
date.to_s,
Rails.cache.read("dawarich_map_tiles_usage:#{current_user.id}:#{date}") || 0
]
end
end
def update
current_user.settings['maps'] = settings_params
current_user.save!
redirect_to settings_maps_path, notice: 'Settings updated'
end
private
def settings_params
params.require(:maps).permit(:name, :url)
end
end

View file

@ -120,4 +120,10 @@ module ApplicationHelper
'text-red-500'
end
def point_speed(speed)
return speed if speed.to_i <= 0
speed * 3.6
end
end

View file

@ -0,0 +1,67 @@
import { Controller } from "@hotwired/stimulus"
import L from "leaflet"
import { showFlashMessage } from "../maps/helpers"
export default class extends Controller {
static targets = ["urlInput", "mapContainer", "saveButton"]
DEFAULT_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
connect() {
console.log("Controller connected!")
// Wait for the next frame to ensure the DOM is ready
requestAnimationFrame(() => {
// Force container height
this.mapContainerTarget.style.height = '500px'
this.initializeMap()
})
}
initializeMap() {
console.log("Initializing map...")
if (!this.map) {
this.map = L.map(this.mapContainerTarget).setView([51.505, -0.09], 13)
// Invalidate size after initialization
setTimeout(() => {
this.map.invalidateSize()
}, 0)
this.updatePreview()
}
}
updatePreview() {
console.log("Updating preview...")
const url = this.urlInputTarget.value || this.DEFAULT_TILE_URL
// Only animate if save button target exists
if (this.hasSaveButtonTarget) {
this.saveButtonTarget.classList.add('btn-animate')
setTimeout(() => {
this.saveButtonTarget.classList.remove('btn-animate')
}, 1000)
}
if (this.currentLayer) {
this.map.removeLayer(this.currentLayer)
}
try {
this.currentLayer = L.tileLayer(url, {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(this.map)
} catch (e) {
console.error('Invalid tile URL:', e)
showFlashMessage('error', 'Invalid tile URL. Reverting to OpenStreetMap.')
// Reset input to default OSM URL
this.urlInputTarget.value = this.DEFAULT_TILE_URL
// Create default layer
this.currentLayer = L.tileLayer(this.DEFAULT_TILE_URL, {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(this.map)
}
}
}

View file

@ -8,9 +8,7 @@ import { createMarkersArray } from "../maps/markers";
import {
createPolylinesLayer,
updatePolylinesOpacity,
updatePolylinesColors,
calculateSpeed,
getSpeedColor
updatePolylinesColors
} from "../maps/polylines";
import { fetchAndDrawAreas, handleAreaCreated } from "../maps/areas";
@ -32,6 +30,7 @@ import { countryCodesMap } from "../maps/country_codes";
import "leaflet-draw";
import { initializeFogCanvas, drawFogCanvas, createFogOverlay } from "../maps/fog_of_war";
import { TileMonitor } from "../maps/tile_monitor";
export default class extends Controller {
static targets = ["container"];
@ -245,6 +244,19 @@ export default class extends Controller {
if (this.liveMapEnabled) {
this.setupSubscription();
}
// Initialize tile monitor
this.tileMonitor = new TileMonitor(this.apiKey);
// Add tile load event handlers to each base layer
Object.entries(this.baseMaps()).forEach(([name, layer]) => {
layer.on('tileload', () => {
this.tileMonitor.recordTileLoad(name);
});
});
// Start monitoring
this.tileMonitor.startMonitoring();
}
disconnect() {
@ -260,6 +272,11 @@ export default class extends Controller {
if (this.map) {
this.map.remove();
}
// Stop tile monitoring
if (this.tileMonitor) {
this.tileMonitor.stopMonitoring();
}
}
setupSubscription() {
@ -385,8 +402,7 @@ export default class extends Controller {
baseMaps() {
let selectedLayerName = this.userSettings.preferred_map_layer || "OpenStreetMap";
return {
let maps = {
OpenStreetMap: osmMapLayer(this.map, selectedLayerName),
"OpenStreetMap.HOT": osmHotMapLayer(this.map, selectedLayerName),
OPNV: OPNVMapLayer(this.map, selectedLayerName),
@ -397,6 +413,33 @@ export default class extends Controller {
esriWorldImagery: esriWorldImageryMapLayer(this.map, selectedLayerName),
esriWorldGrayCanvas: esriWorldGrayCanvasMapLayer(this.map, selectedLayerName)
};
// Add custom map if it exists in settings
if (this.userSettings.maps && this.userSettings.maps.url) {
const customLayer = L.tileLayer(this.userSettings.maps.url, {
maxZoom: 19,
attribution: "&copy; OpenStreetMap contributors"
});
// If this is the preferred layer, add it to the map immediately
if (selectedLayerName === this.userSettings.maps.name) {
customLayer.addTo(this.map);
// Remove any other base layers that might be active
Object.values(maps).forEach(layer => {
if (this.map.hasLayer(layer)) {
this.map.removeLayer(layer);
}
});
}
maps[this.userSettings.maps.name] = customLayer;
} else {
// If no custom map is set, ensure a default layer is added
const defaultLayer = maps[selectedLayerName] || maps["OpenStreetMap"];
defaultLayer.addTo(this.map);
}
return maps;
}
removeEventListeners() {

View file

@ -85,15 +85,12 @@ export function createFogOverlay() {
onAdd: (map) => {
initializeFogCanvas(map);
// Add drag event handlers to update fog during marker movement
map.on('drag', () => {
const fog = document.getElementById('fog');
if (fog) {
// Update fog canvas position to match map position
const mapPos = map.getContainer().getBoundingClientRect();
fog.style.left = `${mapPos.left}px`;
fog.style.top = `${mapPos.top}px`;
}
// Add resize event handlers to update fog size
map.on('resize', () => {
// Set canvas size to match map container
const mapSize = map.getSize();
fog.width = mapSize.x;
fog.height = mapSize.y;
});
},
onRemove: (map) => {
@ -102,7 +99,7 @@ export function createFogOverlay() {
fog.remove();
}
// Clean up event listener
map.off('drag');
map.off('resize');
}
});
}

View file

@ -0,0 +1,63 @@
export class TileMonitor {
constructor(apiKey) {
this.apiKey = apiKey;
this.tileQueue = 0;
this.tileUpdateInterval = null;
}
startMonitoring() {
// Clear any existing interval
if (this.tileUpdateInterval) {
clearInterval(this.tileUpdateInterval);
}
// Set up a regular interval to send stats
this.tileUpdateInterval = setInterval(() => {
this.sendTileUsage();
}, 5000); // Exactly every 5 seconds
}
stopMonitoring() {
if (this.tileUpdateInterval) {
clearInterval(this.tileUpdateInterval);
this.sendTileUsage(); // Send any remaining stats
}
}
recordTileLoad() {
this.tileQueue += 1;
}
sendTileUsage() {
if (this.tileQueue === 0) return;
const currentCount = this.tileQueue;
console.log('Sending tile usage batch:', currentCount);
fetch('/api/v1/maps/tile_usage', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`
},
body: JSON.stringify({
tile_usage: {
count: currentCount
}
})
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Only subtract sent count if it hasn't changed
if (this.tileQueue === currentCount) {
this.tileQueue = 0;
} else {
this.tileQueue -= currentCount;
}
console.log('Tile usage batch sent successfully');
})
.catch(error => console.error('Error recording tile usage:', error));
}
}

View file

@ -16,13 +16,18 @@ class User < ApplicationRecord
has_many :trips, dependent: :destroy
after_create :create_api_key
before_save :strip_trailing_slashes
before_save :sanitize_input
validates :email, presence: true
validates :reset_password_token, uniqueness: true, allow_nil: true
attribute :admin, :boolean, default: false
def safe_settings
Users::SafeSettings.new(settings)
end
def countries_visited
stats.pluck(:toponyms).flatten.map { _1['country'] }.uniq.compact
end
@ -99,8 +104,9 @@ class User < ApplicationRecord
save
end
def strip_trailing_slashes
def sanitize_input
settings['immich_url']&.gsub!(%r{/+\z}, '')
settings['photoprism_url']&.gsub!(%r{/+\z}, '')
settings.try(:[], 'maps')&.try(:[], 'url')&.strip!
end
end

View file

@ -6,8 +6,8 @@ class Areas::Visits::Create
def initialize(user, areas)
@user = user
@areas = areas
@time_threshold_minutes = 30 || user.settings['time_threshold_minutes']
@merge_threshold_minutes = 15 || user.settings['merge_threshold_minutes']
@time_threshold_minutes = 30 || user.safe_settings.time_threshold_minutes
@merge_threshold_minutes = 15 || user.safe_settings.merge_threshold_minutes
end
def call

View file

@ -5,15 +5,15 @@ class Immich::RequestPhotos
def initialize(user, start_date: '1970-01-01', end_date: nil)
@user = user
@immich_api_base_url = URI.parse("#{user.settings['immich_url']}/api/search/metadata")
@immich_api_key = user.settings['immich_api_key']
@immich_api_base_url = URI.parse("#{user.safe_settings.immich_url}/api/search/metadata")
@immich_api_key = user.safe_settings.immich_api_key
@start_date = start_date
@end_date = end_date
end
def call
raise ArgumentError, 'Immich API key is missing' if immich_api_key.blank?
raise ArgumentError, 'Immich URL is missing' if user.settings['immich_url'].blank?
raise ArgumentError, 'Immich URL is missing' if user.safe_settings.immich_url.blank?
data = retrieve_immich_data

View file

@ -0,0 +1,36 @@
# frozen_string_literal: true
class Maps::TileUsage::Track
def initialize(user_id, count = 1)
@user_id = user_id
@count = count
end
def call
report_to_prometheus
report_to_cache
rescue StandardError => e
Rails.logger.error("Failed to send tile usage metric: #{e.message}")
end
private
def report_to_prometheus
return unless DawarichSettings.prometheus_exporter_enabled?
metric_data = {
type: 'counter',
name: 'dawarich_map_tiles_usage',
value: @count
}
PrometheusExporter::Client.default.send_json(metric_data)
end
def report_to_cache
today_key = "dawarich_map_tiles_usage:#{@user_id}:#{Time.zone.today}"
current_value = (Rails.cache.read(today_key) || 0).to_i
Rails.cache.write(today_key, current_value + @count, expires_in: 7.days)
end
end

View file

@ -9,14 +9,14 @@ class Photoprism::RequestPhotos
def initialize(user, start_date: '1970-01-01', end_date: nil)
@user = user
@photoprism_api_base_url = URI.parse("#{user.settings['photoprism_url']}/api/v1/photos")
@photoprism_api_key = user.settings['photoprism_api_key']
@photoprism_api_base_url = URI.parse("#{user.safe_settings.photoprism_url}/api/v1/photos")
@photoprism_api_key = user.safe_settings.photoprism_api_key
@start_date = start_date
@end_date = end_date
end
def call
raise ArgumentError, 'Photoprism URL is missing' if user.settings['photoprism_url'].blank?
raise ArgumentError, 'Photoprism URL is missing' if user.safe_settings.photoprism_url.blank?
raise ArgumentError, 'Photoprism API key is missing' if photoprism_api_key.blank?
data = retrieve_photoprism_data

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true
class Photos::Thumbnail
SUPPORTED_SOURCES = %w[immich photoprism].freeze
def initialize(user, source, id)
@user = user
@source = source
@ -8,6 +10,8 @@ class Photos::Thumbnail
end
def call
raise unsupported_source_error unless SUPPORTED_SOURCES.include?(source)
HTTParty.get(request_url, headers: headers)
end
@ -16,11 +20,11 @@ class Photos::Thumbnail
attr_reader :user, :source, :id
def source_url
user.settings["#{source}_url"]
user.safe_settings.public_send("#{source}_url")
end
def source_api_key
user.settings["#{source}_api_key"]
user.safe_settings.public_send("#{source}_api_key")
end
def source_path
@ -30,8 +34,6 @@ class Photos::Thumbnail
when 'photoprism'
preview_token = Rails.cache.read("#{Photoprism::CachePreviewToken::TOKEN_CACHE_KEY}_#{user.id}")
"/api/v1/t/#{id}/#{preview_token}/tile_500"
else
raise "Unsupported source: #{source}"
end
end
@ -48,4 +50,8 @@ class Photos::Thumbnail
request_headers
end
def unsupported_source_error
raise ArgumentError, "Unsupported source: #{source}"
end
end

View file

@ -96,7 +96,13 @@ class ReverseGeocoding::Places::FetchData
end
def reverse_geocoded_places
data = Geocoder.search([place.latitude, place.longitude], limit: 10, distance_sort: true)
data = Geocoder.search(
[place.latitude, place.longitude],
limit: 10,
distance_sort: true,
radius: 1,
units: DISTANCE_UNITS
)
data.reject do |place|
place.data['properties']['osm_value'].in?(IGNORED_OSM_VALUES) ||

View file

@ -0,0 +1,93 @@
# frozen_string_literal: true
class Users::SafeSettings
attr_reader :settings
def initialize(settings)
@settings = settings
end
# rubocop:disable Metrics/MethodLength
def config
{
fog_of_war_meters: fog_of_war_meters,
meters_between_routes: meters_between_routes,
preferred_map_layer: preferred_map_layer,
speed_colored_routes: speed_colored_routes,
points_rendering_mode: points_rendering_mode,
minutes_between_routes: minutes_between_routes,
time_threshold_minutes: time_threshold_minutes,
merge_threshold_minutes: merge_threshold_minutes,
live_map_enabled: live_map_enabled,
route_opacity: route_opacity,
immich_url: immich_url,
immich_api_key: immich_api_key,
photoprism_url: photoprism_url,
photoprism_api_key: photoprism_api_key,
maps: maps
}
end
# rubocop:enable Metrics/MethodLength
def fog_of_war_meters
settings['fog_of_war_meters'] || 50
end
def meters_between_routes
settings['meters_between_routes'] || 500
end
def preferred_map_layer
settings['preferred_map_layer'] || 'OpenStreetMap'
end
def speed_colored_routes
settings['speed_colored_routes'] || false
end
def points_rendering_mode
settings['points_rendering_mode'] || 'raw'
end
def minutes_between_routes
settings['minutes_between_routes'] || 30
end
def time_threshold_minutes
settings['time_threshold_minutes'] || 30
end
def merge_threshold_minutes
settings['merge_threshold_minutes'] || 15
end
def live_map_enabled
return settings['live_map_enabled'] if settings.key?('live_map_enabled')
true
end
def route_opacity
settings['route_opacity'] || 0.6
end
def immich_url
settings['immich_url']
end
def immich_api_key
settings['immich_api_key']
end
def photoprism_url
settings['photoprism_url']
end
def photoprism_api_key
settings['photoprism_api_key']
end
def maps
settings['maps'] || {}
end
end

View file

@ -5,12 +5,12 @@
<h1 class="font-bold text-4xl">Imports</h1>
<%= link_to "New import", new_import_path, class: "rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %>
<% if current_user.settings['immich_url'] && current_user.settings['immich_api_key'] %>
<% if current_user.safe_settings.immich_url && current_user.safe_settings.immich_api_key %>
<%= link_to 'Import Immich data', settings_background_jobs_path(job_name: 'start_immich_import'), method: :post, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :post }, class: 'rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium' %>
<% else %>
<a href='' class="rounded-lg py-3 px-5 bg-blue-900 text-gray block font-medium tooltip cursor-not-allowed" data-tip="You need to provide your Immich instance data in the Settings">Import Immich data</a>
<% end %>
<% if current_user.settings['photoprism_url'] && current_user.settings['photoprism_api_key'] %>
<% if current_user.safe_settings.photoprism_url && current_user.safe_settings.photoprism_api_key %>
<%= link_to 'Import Photoprism data', settings_background_jobs_path(job_name: 'start_photoprism_import'), method: :post, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :post }, class: 'rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium' %>
<% else %>
<a href='' class="rounded-lg py-3 px-5 bg-blue-900 text-gray block font-medium tooltip cursor-not-allowed" data-tip="You need to provide your Photoprism instance data in the Settings">Import Photoprism data</a>

View file

@ -49,7 +49,7 @@
data-points-target="map"
data-distance_unit="<%= DISTANCE_UNIT %>"
data-api_key="<%= current_user.api_key %>"
data-user_settings=<%= current_user.settings.to_json %>
data-user_settings='<%= current_user.settings.to_json.html_safe %>'
data-coordinates="<%= @coordinates %>"
data-distance="<%= @distance %>"
data-points_number="<%= @points_number %>"

View file

@ -4,4 +4,5 @@
<%= link_to 'Users', settings_users_path, role: 'tab', class: "tab #{active_tab?(settings_users_path)}" %>
<%= link_to 'Background Jobs', settings_background_jobs_path, role: 'tab', class: "tab #{active_tab?(settings_background_jobs_path)}" %>
<% end %>
<%= link_to 'Map', settings_maps_path, role: 'tab', class: "tab #{active_tab?(settings_maps_path)}" %>
</div>

View file

@ -9,20 +9,20 @@
<%= form_for :settings, url: settings_path, method: :patch, data: { turbo_method: :patch, turbo: false } do |f| %>
<div class="form-control my-2">
<%= f.label :immich_url %>
<%= f.text_field :immich_url, value: current_user.settings['immich_url'], class: "input input-bordered", placeholder: 'http://192.168.0.1:2283' %>
<%= f.text_field :immich_url, value: current_user.safe_settings.immich_url, class: "input input-bordered", placeholder: 'http://192.168.0.1:2283' %>
</div>
<div class="form-control my-2">
<%= f.label :immich_api_key %>
<%= f.text_field :immich_api_key, value: current_user.settings['immich_api_key'], class: "input input-bordered", placeholder: 'xxxxxxxxxxxxxx' %>
<%= f.text_field :immich_api_key, value: current_user.safe_settings.immich_api_key, class: "input input-bordered", placeholder: 'xxxxxxxxxxxxxx' %>
</div>
<div class="divider"></div>
<div class="form-control my-2">
<%= f.label :photoprism_url %>
<%= f.text_field :photoprism_url, value: current_user.settings['photoprism_url'], class: "input input-bordered", placeholder: 'http://192.168.0.1:2342' %>
<%= f.text_field :photoprism_url, value: current_user.safe_settings.photoprism_url, class: "input input-bordered", placeholder: 'http://192.168.0.1:2342' %>
</div>
<div class="form-control my-2">
<%= f.label :photoprism_api_key %>
<%= f.text_field :photoprism_api_key, value: current_user.settings['photoprism_api_key'], class: "input input-bordered", placeholder: 'xxxxxxxxxxxxxx' %>
<%= f.text_field :photoprism_api_key, value: current_user.safe_settings.photoprism_api_key, class: "input input-bordered", placeholder: 'xxxxxxxxxxxxxx' %>
</div>
<div class="form-control my-2">

View file

@ -0,0 +1,71 @@
<% content_for :title, "Background jobs" %>
<div class="min-h-content w-full my-5">
<%= render 'settings/navigation' %>
<div class="flex justify-between items-center my-5">
<h1 class="font-bold text-4xl">Maps settings</h1>
</div>
<div role="alert" class="alert alert-info">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>Please remember, that using a custom tile URL may result in extra costs. Check your map tile provider's terms of service for more information.</span>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-5" data-controller="map-preview">
<div class="flex flex-col gap-4">
<%= form_for :maps,
url: settings_maps_path,
method: :patch,
autocomplete: "off",
data: { turbo_method: :patch, turbo: false } do |f| %>
<div class="form-control my-2">
<%= f.label :name %>
<%= f.text_field :name, value: @maps['name'], placeholder: 'Example: OpenStreetMap', class: "input input-bordered" %>
</div>
<div class="form-control my-2">
<%= f.label :url, 'URL' %>
<%= f.text_field :url,
value: @maps['url'],
autocomplete: "off",
placeholder: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
class: "input input-bordered",
data: {
map_preview_target: "urlInput",
action: "input->map-preview#updatePreview"
} %>
</div>
<%= f.submit 'Save', class: "btn btn-primary", data: { map_preview_target: "saveButton" } %>
<% end %>
<h2 class="text-lg font-bold">Tile usage</h2>
<%= line_chart(
@tile_usage,
height: '200px',
xtitle: 'Days',
ytitle: 'Tiles',
suffix: ' tiles loaded'
) %>
</div>
<div style="height: 500px;">
<div
data-map-preview-target="mapContainer"
class="w-full h-full rounded-lg border"
></div>
</div>
</div>
</div>

View file

@ -13,5 +13,9 @@ PHOTON_API_HOST = ENV.fetch('PHOTON_API_HOST', nil)
PHOTON_API_KEY = ENV.fetch('PHOTON_API_KEY', nil)
PHOTON_API_USE_HTTPS = ENV.fetch('PHOTON_API_USE_HTTPS', 'true') == 'true'
NOMINATIM_API_HOST = ENV.fetch('NOMINATIM_API_HOST', nil)
NOMINATIM_API_KEY = ENV.fetch('NOMINATIM_API_KEY', nil)
NOMINATIM_API_USE_HTTPS = ENV.fetch('NOMINATIM_API_USE_HTTPS', 'true') == 'true'
GEOAPIFY_API_KEY = ENV.fetch('GEOAPIFY_API_KEY', nil)
# /Reverse geocoding settings

View file

@ -3,7 +3,7 @@
class DawarichSettings
class << self
def reverse_geocoding_enabled?
@reverse_geocoding_enabled ||= photon_enabled? || geoapify_enabled?
@reverse_geocoding_enabled ||= photon_enabled? || geoapify_enabled? || nominatim_enabled?
end
def photon_enabled?
@ -18,12 +18,15 @@ class DawarichSettings
@geoapify_enabled ||= GEOAPIFY_API_KEY.present?
end
def meters_between_tracks
@meters_between_tracks ||= 300
def prometheus_exporter_enabled?
@prometheus_exporter_enabled ||=
ENV['PROMETHEUS_EXPORTER_ENABLED'].to_s == 'true' &&
ENV['PROMETHEUS_EXPORTER_HOST'].present? &&
ENV['PROMETHEUS_EXPORTER_PORT'].present?
end
def minutes_between_tracks
@minutes_between_tracks ||= 20
def nominatim_enabled?
@nominatim_enabled ||= NOMINATIM_API_HOST.present?
end
end
end

View file

@ -19,6 +19,12 @@ if PHOTON_API_HOST.present?
elsif GEOAPIFY_API_KEY.present?
settings[:lookup] = :geoapify
settings[:api_key] = GEOAPIFY_API_KEY
elsif NOMINATIM_API_HOST.present?
settings[:lookup] = :nominatim
settings[:nominatim] = { use_https: NOMINATIM_API_USE_HTTPS, host: NOMINATIM_API_HOST }
if NOMINATIM_API_KEY.present?
settings[:api_key] = NOMINATIM_API_KEY
end
end
Geocoder.configure(settings)

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true
if !Rails.env.test? && ENV['PROMETHEUS_EXPORTER_ENABLED'].to_s == 'true'
if !Rails.env.test? && DawarichSettings.prometheus_exporter_enabled?
require 'prometheus_exporter/middleware'
require 'prometheus_exporter/instrumentation'

View file

@ -1,7 +0,0 @@
# frozen_string_literal: true
module Reddis
def self.client
@client ||= Redis.new(url: ENV['REDIS_URL'])
end
end

View file

@ -20,6 +20,8 @@ Rails.application.routes.draw do
namespace :settings do
resources :background_jobs, only: %i[index create destroy]
resources :users, only: %i[index create destroy edit update]
resources :maps, only: %i[index]
patch 'maps', to: 'maps#update'
end
patch 'settings', to: 'settings#update'
@ -94,6 +96,10 @@ Rails.application.routes.draw do
get 'thumbnail', constraints: { id: %r{[^/]+} }
end
end
namespace :maps do
resources :tile_usage, only: [:create]
end
end
end
end

View file

@ -24,7 +24,7 @@ fi
# Wait for the database to become available
echo "⏳ Waiting for database to be ready..."
until PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -d "$DATABASE_NAME" -c '\q'; do
until PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -c '\q'; do
>&2 echo "Postgres is unavailable - retrying..."
sleep 2
done

View file

@ -29,14 +29,14 @@ rm -f $APP_PATH/tmp/pids/server.pid
# Wait for the database to become available
echo "⏳ Waiting for database to be ready..."
until PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -d "$DATABASE_NAME" -c '\q'; do
until PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -c '\q'; do
>&2 echo "Postgres is unavailable - retrying..."
sleep 2
done
echo "✅ PostgreSQL is ready!"
# Create database if it doesn't exist
if ! PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -d "$DATABASE_NAME" -c "SELECT 1 FROM pg_database WHERE datname='$DATABASE_NAME'" | grep -q 1; then
if ! PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -c "SELECT 1 FROM pg_database WHERE datname='$DATABASE_NAME'" | grep -q 1; then
echo "Creating database $DATABASE_NAME..."
bundle exec rails db:create
fi

View file

@ -0,0 +1,36 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Api::V1::Maps::TileUsage', type: :request do
describe 'POST /api/v1/maps/tile_usage' do
let(:tile_count) { 5 }
let(:track_service) { instance_double(Maps::TileUsage::Track) }
let(:user) { create(:user) }
before do
allow(Maps::TileUsage::Track).to receive(:new).with(user.id, tile_count).and_return(track_service)
allow(track_service).to receive(:call)
end
context 'when user is authenticated' do
it 'tracks tile usage' do
post '/api/v1/maps/tile_usage',
params: { tile_usage: { count: tile_count } },
headers: { 'Authorization' => "Bearer #{user.api_key}" }
expect(Maps::TileUsage::Track).to have_received(:new).with(user.id, tile_count)
expect(track_service).to have_received(:call)
expect(response).to have_http_status(:ok)
end
end
context 'when user is not authenticated' do
it 'returns unauthorized' do
post '/api/v1/maps/tile_usage', params: { tile_usage: { count: tile_count } }
expect(response).to have_http_status(:unauthorized)
end
end
end
end

View file

@ -0,0 +1,43 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'settings/maps', type: :request do
before do
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
end
context 'when user is authenticated' do
let!(:user) { create(:user) }
before do
sign_in user
end
describe 'GET /index' do
it 'returns a success response' do
get settings_maps_url
expect(response).to be_successful
end
end
describe 'PATCH /update' do
it 'returns a success response' do
patch settings_maps_path, params: { maps: { name: 'Test', url: 'https://test.com' } }
expect(response).to redirect_to(settings_maps_path)
expect(user.settings['maps']).to eq({ 'name' => 'Test', 'url' => 'https://test.com' })
end
end
end
context 'when user is not authenticated' do
it 'redirects to the sign in page' do
get settings_maps_path
expect(response).to redirect_to(new_user_session_path)
end
end
end

View file

@ -0,0 +1,42 @@
# frozen_string_literal: true
require 'rails_helper'
require 'prometheus_exporter/client'
RSpec.describe Maps::TileUsage::Track do
describe '#call' do
subject(:track) { described_class.new(user_id, tile_count).call }
let(:user_id) { 1 }
let(:tile_count) { 5 }
let(:prometheus_client) { instance_double(PrometheusExporter::Client) }
before do
allow(PrometheusExporter::Client).to receive(:default).and_return(prometheus_client)
allow(prometheus_client).to receive(:send_json)
allow(DawarichSettings).to receive(:prometheus_exporter_enabled?).and_return(true)
end
it 'tracks tile usage in prometheus' do
expect(prometheus_client).to receive(:send_json).with(
{
type: 'counter',
name: 'dawarich_map_tiles_usage',
value: tile_count
}
)
track
end
it 'tracks tile usage in cache' do
expect(Rails.cache).to receive(:write).with(
"dawarich_map_tiles_usage:#{user_id}:#{Time.zone.today}",
tile_count,
expires_in: 7.days
)
track
end
end
end

View file

@ -70,7 +70,7 @@ RSpec.describe Photos::Thumbnail do
let(:source) { 'unsupported' }
it 'raises an error' do
expect { subject }.to raise_error(RuntimeError, 'Unsupported source: unsupported')
expect { subject }.to raise_error(ArgumentError, 'Unsupported source: unsupported')
end
end
end

View file

@ -0,0 +1,143 @@
# frozen_string_literal: true
RSpec.describe Users::SafeSettings do
describe '#config' do
context 'with default values' do
let(:settings) { {} }
let(:safe_settings) { described_class.new(settings) }
it 'returns default configuration' do
expect(safe_settings.config).to eq(
{
fog_of_war_meters: 50,
meters_between_routes: 500,
preferred_map_layer: 'OpenStreetMap',
speed_colored_routes: false,
points_rendering_mode: 'raw',
minutes_between_routes: 30,
time_threshold_minutes: 30,
merge_threshold_minutes: 15,
live_map_enabled: true,
route_opacity: 0.6,
immich_url: nil,
immich_api_key: nil,
photoprism_url: nil,
photoprism_api_key: nil,
maps: {}
}
)
end
end
context 'with custom values' do
let(:settings) do
{
'fog_of_war_meters' => 100,
'meters_between_routes' => 1000,
'preferred_map_layer' => 'Satellite',
'speed_colored_routes' => true,
'points_rendering_mode' => 'simplified',
'minutes_between_routes' => 60,
'time_threshold_minutes' => 45,
'merge_threshold_minutes' => 20,
'live_map_enabled' => false,
'route_opacity' => 0.8,
'immich_url' => 'https://immich.example.com',
'immich_api_key' => 'immich-key',
'photoprism_url' => 'https://photoprism.example.com',
'photoprism_api_key' => 'photoprism-key',
'maps' => { 'name' => 'custom', 'url' => 'https://custom.example.com' }
}
end
let(:safe_settings) { described_class.new(settings) }
it 'returns custom configuration' do
expect(safe_settings.config).to eq(
{
fog_of_war_meters: 100,
meters_between_routes: 1000,
preferred_map_layer: 'Satellite',
speed_colored_routes: true,
points_rendering_mode: 'simplified',
minutes_between_routes: 60,
time_threshold_minutes: 45,
merge_threshold_minutes: 20,
live_map_enabled: false,
route_opacity: 0.8,
immich_url: 'https://immich.example.com',
immich_api_key: 'immich-key',
photoprism_url: 'https://photoprism.example.com',
photoprism_api_key: 'photoprism-key',
maps: { 'name' => 'custom', 'url' => 'https://custom.example.com' }
}
)
end
end
end
describe 'individual settings' do
let(:safe_settings) { described_class.new(settings) }
context 'with default values' do
let(:settings) { {} }
it 'returns default values for each setting' do
expect(safe_settings.fog_of_war_meters).to eq(50)
expect(safe_settings.meters_between_routes).to eq(500)
expect(safe_settings.preferred_map_layer).to eq('OpenStreetMap')
expect(safe_settings.speed_colored_routes).to be false
expect(safe_settings.points_rendering_mode).to eq('raw')
expect(safe_settings.minutes_between_routes).to eq(30)
expect(safe_settings.time_threshold_minutes).to eq(30)
expect(safe_settings.merge_threshold_minutes).to eq(15)
expect(safe_settings.live_map_enabled).to be true
expect(safe_settings.route_opacity).to eq(0.6)
expect(safe_settings.immich_url).to be_nil
expect(safe_settings.immich_api_key).to be_nil
expect(safe_settings.photoprism_url).to be_nil
expect(safe_settings.photoprism_api_key).to be_nil
expect(safe_settings.maps).to eq({})
end
end
context 'with custom values' do
let(:settings) do
{
'fog_of_war_meters' => 100,
'meters_between_routes' => 1000,
'preferred_map_layer' => 'Satellite',
'speed_colored_routes' => true,
'points_rendering_mode' => 'simplified',
'minutes_between_routes' => 60,
'time_threshold_minutes' => 45,
'merge_threshold_minutes' => 20,
'live_map_enabled' => false,
'route_opacity' => 0.8,
'immich_url' => 'https://immich.example.com',
'immich_api_key' => 'immich-key',
'photoprism_url' => 'https://photoprism.example.com',
'photoprism_api_key' => 'photoprism-key',
'maps' => { 'name' => 'custom', 'url' => 'https://custom.example.com' }
}
end
it 'returns custom values for each setting' do
expect(safe_settings.fog_of_war_meters).to eq(100)
expect(safe_settings.meters_between_routes).to eq(1000)
expect(safe_settings.preferred_map_layer).to eq('Satellite')
expect(safe_settings.speed_colored_routes).to be true
expect(safe_settings.points_rendering_mode).to eq('simplified')
expect(safe_settings.minutes_between_routes).to eq(60)
expect(safe_settings.time_threshold_minutes).to eq(45)
expect(safe_settings.merge_threshold_minutes).to eq(20)
expect(safe_settings.live_map_enabled).to be false
expect(safe_settings.route_opacity).to eq(0.8)
expect(safe_settings.immich_url).to eq('https://immich.example.com')
expect(safe_settings.immich_api_key).to eq('immich-key')
expect(safe_settings.photoprism_url).to eq('https://photoprism.example.com')
expect(safe_settings.photoprism_api_key).to eq('photoprism-key')
expect(safe_settings.maps).to eq({ 'name' => 'custom', 'url' => 'https://custom.example.com' })
end
end
end
end