Merge pull request #62 from Freika/exports

Exports
This commit is contained in:
Evgenii Burmakin 2024-06-12 21:13:30 +02:00 committed by GitHub
commit 3875429c46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 502 additions and 128 deletions

View file

@ -1 +1 @@
0.5.3
0.6.0

2
.gitignore vendored
View file

@ -26,6 +26,8 @@
!/tmp/storage/.keep
/public/assets
/public/exports
/public/imports
# Ignore master key for decrypting credentials and more.
/config/master.key

View file

@ -5,6 +5,51 @@ 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.6.0] — 2024-06-12
### Added
- Exports page to list existing exports download them or delete them
### Changed
- Exporting process now is done in the background, so user can close the browser tab and come back later to download the file. The status of the export can be checked on the Exports page.
Deleting Export file will only delete the file, not the points in the database.
⚠️ BREAKING CHANGES: ⚠️
Volume, exposed to the host machine for placing files to import was changed. See the changes below.
Path for placing files to import was changed from `tmp/imports` to `public/imports`.
```diff
...
dawarich_app:
image: freikin/dawarich:latest
container_name: dawarich_app
volumes:
- gem_cache:/usr/local/bundle/gems
- - tmp:/var/app/tmp
+ - public:/var/app/public/imports
...
```
```diff
...
volumes:
db_data:
gem_cache:
shared_data:
- tmp:
+ public:
```
---
## [0.5.3] — 2024-06-10
### Added

View file

@ -63,6 +63,8 @@ You can see the number of countries and cities visited, the distance traveled, a
You can import your Google Maps Timeline data into Dawarich as well as Owntracks data.
⚠️ **Note**: Import of huge Google Maps Timeline files may take a long time and consume a lot of memory. It also might temporarily consume a lot of disk space due to logs. Please make sure you have enough resources before starting the import. After import is completed, you can restart your docker container and logs will be removed.
## How to start the app locally
`docker-compose up` to start the app. The app will be available at `http://localhost:3000`.

File diff suppressed because one or more lines are too long

View file

@ -1,32 +0,0 @@
# frozen_string_literal: true
class ExportController < ApplicationController
before_action :authenticate_user!
def download
export = current_user.export_data(start_at:, end_at:)
send_data export, filename:, type: 'applocation/json', disposition: 'attachment'
end
private
def filename
first_point_datetime = Time.zone.at(start_at).to_s
last_point_datetime = Time.zone.at(end_at).to_s
"dawarich-export-#{first_point_datetime}-#{last_point_datetime}.json".gsub(' ', '_')
end
def start_at
first_point_timestamp = current_user.tracked_points.order(timestamp: :asc)&.first&.timestamp
@start_at ||= first_point_timestamp || 1.month.ago.to_i
end
def end_at
last_point_timestamp = current_user.tracked_points.order(timestamp: :asc)&.last&.timestamp
@end_at ||= last_point_timestamp || Time.current.to_i
end
end

View file

@ -0,0 +1,39 @@
# frozen_string_literal: true
class ExportsController < ApplicationController
before_action :authenticate_user!
before_action :set_export, only: %i[destroy]
def index
@exports = current_user.exports.order(created_at: :desc).page(params[:page])
end
def create
export_name = "#{params[:start_at].to_date}_#{params[:end_at].to_date}"
export = current_user.exports.create(name: export_name, status: :created)
ExportJob.perform_later(export.id, params[:start_at], params[:end_at])
redirect_to exports_url, notice: 'Export was successfully initiated. Please wait until it\'s finished.'
rescue StandardError => e
export&.destroy
redirect_to exports_url, alert: "Export failed to initiate: #{e.message}", status: :unprocessable_entity
end
def destroy
@export.destroy
redirect_to exports_url, notice: 'Export was successfully destroyed.', status: :see_other
end
private
def set_export
@export = current_user.exports.find(params[:id])
end
def export_params
params.require(:export).permit(:name, :url, :status)
end
end

View file

@ -104,4 +104,9 @@ module ApplicationHelper
def active_class?(link_path)
'btn-active' if current_page?(link_path)
end
def full_title(page_title = '')
base_title = 'Dawarich'
page_title.empty? ? base_title : "#{page_title} | #{base_title}"
end
end

View file

@ -0,0 +1,2 @@
module ExportsHelper
end

11
app/jobs/export_job.rb Normal file
View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class ExportJob < ApplicationJob
queue_as :exports
def perform(export_id, start_at, end_at)
export = Export.find(export_id)
Exports::Create.new(export:, start_at:, end_at:).call
end
end

19
app/models/export.rb Normal file
View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
class Export < ApplicationRecord
belongs_to :user
enum status: { created: 0, processing: 1, completed: 2, failed: 3 }
validates :name, presence: true
before_destroy :delete_export_file
private
def delete_export_file
file_path = Rails.root.join('public', 'exports', "#{name}.json")
File.delete(file_path) if File.exist?(file_path)
end
end

View file

@ -10,15 +10,10 @@ class User < ApplicationRecord
has_many :points, through: :imports
has_many :stats, dependent: :destroy
has_many :tracked_points, class_name: 'Point', dependent: :destroy
has_many :exports, dependent: :destroy
after_create :create_api_key
def export_data(start_at: nil, end_at: nil)
geopoints = time_framed_points(start_at, end_at)
::ExportSerializer.new(geopoints, email).call
end
def countries_visited
stats.pluck(:toponyms).flatten.map { _1['country'] }.uniq.compact
end
@ -59,16 +54,4 @@ class User < ApplicationRecord
save
end
def time_framed_points(start_at, end_at)
return tracked_points.without_raw_data if start_at.nil? && end_at.nil?
if start_at && end_at
tracked_points.without_raw_data.where('timestamp >= ? AND timestamp <= ?', start_at, end_at)
elsif start_at
tracked_points.without_raw_data.where('timestamp >= ?', start_at)
elsif end_at
tracked_points.without_raw_data.where('timestamp <= ?', end_at)
end
end
end

View file

@ -0,0 +1,34 @@
# frozen_string_literal: true
class Exports::Create
def initialize(export:, start_at:, end_at:)
@export = export
@user = export.user
@start_at = start_at
@end_at = end_at
end
def call
export.update!(status: :processing)
points = time_framed_points(start_at, end_at, user)
data = ::ExportSerializer.new(points, user.email).call
file_path = Rails.root.join('public', 'exports', "#{export.name}.json")
File.open(file_path, 'w') { |file| file.write(data) }
export.update!(status: :completed, url: "exports/#{export.name}.json")
rescue StandardError => e
Rails.logger.error("====Export failed to create: #{e.message}")
export.update!(status: :failed)
end
private
attr_reader :user, :export, :start_at, :end_at
def time_framed_points(start_at, end_at, user)
user.tracked_points.without_raw_data.where('timestamp >= ? AND timestamp <= ?', start_at.to_i, end_at.to_i)
end
end

View file

@ -0,0 +1,47 @@
<% content_for :title, "Exports" %>
<div class="w-full">
<div class="flex justify-between items-center mb-5">
<h1 class="font-bold text-4xl">Exports</h1>
</div>
<div id="exports" class="min-w-full">
<% if @exports.empty? %>
<div class="hero min-h-80 bg-base-200">
<div class="hero-content text-center">
<div class="max-w-md">
<h1 class="text-5xl font-bold">Hello there!</h1>
<p class="py-6">
Here you'll find your exports, created on <%= link_to 'Points', points_url, class: 'link' %> page. But now there are none.
</p>
</div>
</div>
</div>
<% else %>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<% @exports.each do |export| %>
<tr>
<td><%= export.name %></td>
<td><%= export.status %></td>
<td>
<% if export.completed? %>
<%= link_to 'Download', export.url, class: "px-4 py-2 bg-blue-500 text-white rounded-md", download: export.name %>
<% end %>
<%= link_to 'Delete', export, data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "px-4 py-2 bg-red-500 text-white rounded-md" %>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</div>
</div>

View file

@ -1,3 +1,5 @@
<% content_for :title, 'Imports' %>
<div class="w-full">
<div class="flex justify-between items-center">
<h1 class="font-bold text-4xl">Imports</h1>
@ -5,6 +7,18 @@
</div>
<div id="imports" class="min-w-full">
<% if @imports.empty? %>
<div class="hero min-h-80 bg-base-200">
<div class="hero-content text-center">
<div class="max-w-md">
<h1 class="text-5xl font-bold">Hello there!</h1>
<p class="py-6">
Here you'll find your imports, But now there are none. Let's <%= link_to 'create one', new_import_path, class: 'link' %>!
</p>
</div>
</div>
</div>
<% else %>
<div class="overflow-x-auto">
<table class="table">
<!-- head -->
@ -33,5 +47,6 @@
</tbody>
</table>
</div>
<% end %>
</div>
</div>

View file

@ -1,3 +1,5 @@
<% content_for :title, 'New Import' %>
<div class="mx-auto md:w-2/3 w-full">
<h1 class="font-bold text-4xl">New import</h1>
@ -8,11 +10,24 @@
<p class='mb-3'>Import takes a while to finish, so you might want to run it in <code>screen</code> session.</p>
<p>1. Upload your Records.json file to your server</p>
<p>2. Copy you Records.json to the <code>tmp</code> folder: <code>$ docker cp Records.json dawarich_app:/var/app/tmp/Records.json</code></p>
<p>3. Attach to the docker container: <code>$ docker exec -it dawarich_app sh</code></p>
<p>4. Run the rake task: <code>$ bundle exec rake import:big_file['tmp/Records.json','user@example.com']</code></p>
<p>5. Wait patiently for process to finish</p>
<p class='mt-5 mb-2'>1. Upload your Records.json file to your server</p>
<p class='mt-5 mb-2'>2. Copy you Records.json to the <code>tmp</code> folder:
<div class="mockup-code">
<pre data-prefix="$"><code>docker cp Records.json dawarich_app:/var/app/public/imports/Records.json</code></pre>
</div>
</p>
<p class='mt-5 mb-2'>3. Attach to the docker container:
<div class="mockup-code">
<pre data-prefix="$"><code>docker exec -it dawarich_app sh</code></pre>
</div>
</p>
<p class='mt-5 mb-2'>4. Run the rake task:
<div class="mockup-code">
<pre data-prefix="$"><code>bundle exec rake import:big_file['public/imports/Records.json','user@example.com']</code>
</pre>
</div>
</p>
<p class='mt-5 mb-2'>5. Wait patiently for process to finish</p>
<p class='mt-3'>You can monitor progress in <a href="/sidekiq" class="underline">Sidekiq UI</a></p>
</span>

View file

@ -1,3 +1,5 @@
<% content_for :title, 'Import' %>
<div class="mx-auto md:w-2/3 w-full flex">
<div class="mx-auto">
<% if notice.present? %>

View file

@ -1,7 +1,7 @@
<!DOCTYPE html>
<html data-theme="<%= app_theme %>">
<head>
<title>DaWarIch</title>
<title><%= full_title(yield(:title)) %></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>

View file

@ -1,3 +1,5 @@
<% content_for :title, 'Map' %>
<div class='w-4/5 mt-10'>
<div class="flex flex-col space-y-4 mb-4 w-full">
<%= form_with url: map_path, method: :get do |f| %>
@ -21,27 +23,22 @@
</div>
<div class="w-full md:w-2/12">
<div class="flex flex-col space-y-2 text-center">
<%= link_to "Today", map_path(start_at: Time.current.beginning_of_day, end_at: Time.current.end_of_day), class: "px-4 py-2 bg-blue-500 text-white rounded-md" %>
<%= link_to "Today", map_path(start_at: Time.current.beginning_of_day, end_at: Time.current.end_of_day), class: "px-4 py-2 bg-gray-500 text-white rounded-md" %>
</div>
</div>
<div class="w-full md:w-2/12">
<div class="flex flex-col space-y-2 text-center">
<%= link_to "Yesterday", map_path(start_at: Date.yesterday.beginning_of_day, end_at: Date.yesterday.end_of_day), class: "px-4 py-2 bg-blue-500 text-white rounded-md" %>
<%= link_to "Yesterday", map_path(start_at: Date.yesterday.beginning_of_day, end_at: Date.yesterday.end_of_day), class: "px-4 py-2 bg-gray-500 text-white rounded-md" %>
</div>
</div>
<div class="w-full md:w-2/12">
<div class="flex flex-col space-y-2 text-center">
<%= link_to "Last 7 days", map_path(start_at: 1.week.ago.beginning_of_day, end_at: Time.current.end_of_day), class: "px-4 py-2 bg-blue-500 text-white rounded-md" %>
<%= link_to "Last 7 days", map_path(start_at: 1.week.ago.beginning_of_day, end_at: Time.current.end_of_day), class: "px-4 py-2 bg-gray-500 text-white rounded-md" %>
</div>
</div>
</div>
<% end %>
<div role="alert" class="alert alert-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>Warning: Starting release 0.4.0 it's HIGHLY RECOMMENDED to switch from <code>/api/v1/points</code> to <code>/api/v1/owntracks/points</code> API endpoint. Please read more at <a href="https://github.com/Freika/dawarich/releases/tag/0.4.0" class='underline hover:no-underline'>0.4.0 release notes</a></span>
</div>
<div
class="w-full"
data-controller="maps"

View file

@ -1,4 +1,4 @@
<% content_for :title, "Points" %>
<% content_for :title, 'Points' %>
<div class="w-full">
<%= form_with url: points_path, method: :get do |f| %>
@ -22,7 +22,7 @@
</div>
<div class="w-full md:w-2/6">
<div class="flex flex-col space-y-2 text-center">
<%= link_to 'Download JSON', export_download_path(start_at: @start_at, end_at: @end_at), data: { turbo: false }, class: "px-4 py-2 bg-blue-500 text-white rounded-md" %>
<%= link_to 'Export points', exports_path(start_at: @start_at, end_at: @end_at), data: { confirm: "Are you sure?", turbo_confirm: "Are you sure? This will start background process of exporting points withing timeframe, selected between #{@start_at} and #{@end_at}", turbo_method: :post }, class: "px-4 py-2 bg-green-500 text-white rounded-md" %>
</div>
</div>
</div>

View file

@ -9,6 +9,7 @@
<li><%= link_to 'Points', points_url, class: "#{active_class?(points_url)}" %></li>
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
<li><%= link_to 'Imports', imports_url, class: "#{active_class?(imports_url)}" %></li>
<li><%= link_to 'Exports', exports_url, class: "#{active_class?(exports_url)}" %></li>
</ul>
</div>
<%= link_to 'DaWarIch', root_path, class: 'btn btn-ghost normal-case text-xl'%>
@ -41,6 +42,7 @@
<li><%= link_to 'Points', points_url, class: "#{active_class?(points_url)}" %></li>
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
<li><%= link_to 'Imports', imports_url, class: "#{active_class?(imports_url)}" %></li>
<li><%= link_to 'Exports', exports_url, class: "#{active_class?(exports_url)}" %></li>
</ul>
</div>
<div class="navbar-end">

View file

@ -1,3 +1,5 @@
<% content_for :title, 'Statistics' %>
<div class="w-full">
<div class="stats stats-vertical lg:stats-horizontal shadow w-full bg-base-200">
<div class="stat text-center">

View file

@ -1,3 +1,5 @@
<% content_for :title, "Statistics for #{@year} year" %>
<div class="w-full">
<%= render partial: 'stats/year', locals: { year: @year, stats: @stats } %>
</div>

View file

@ -11,6 +11,7 @@ Rails.application.routes.draw do
get 'export/download', to: 'export#download'
resources :imports
resources :exports, only: %i[index create destroy]
resources :points, only: %i[index] do
collection do
delete :bulk_destroy

View file

@ -3,5 +3,6 @@
:queues:
- default
- imports
- exports
- stats
- reverse_geocoding

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
class CreateExports < ActiveRecord::Migration[7.1]
def change
create_table :exports do |t|
t.string :name, null: false
t.string :url
t.integer :status, default: 0, null: false
t.bigint :user_id, null: false
t.timestamps
end
add_index :exports, :status
add_index :exports, :user_id
end
end

16
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2024_05_25_110244) do
ActiveRecord::Schema[7.1].define(version: 2024_06_12_152451) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -42,6 +42,20 @@ ActiveRecord::Schema[7.1].define(version: 2024_05_25_110244) do
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end
create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t|
end
create_table "exports", force: :cascade do |t|
t.string "name", null: false
t.string "url"
t.integer "status", default: 0, null: false
t.bigint "user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["status"], name: "index_exports_on_status"
t.index ["user_id"], name: "index_exports_on_user_id"
end
create_table "imports", force: :cascade do |t|
t.string "name", null: false
t.bigint "user_id", null: false

View file

@ -25,7 +25,7 @@ services:
container_name: dawarich_app
volumes:
- gem_cache:/usr/local/bundle/gems
- tmp:/var/app/tmp
- public:/var/app/public
networks:
- dawarich
ports:
@ -78,4 +78,4 @@ volumes:
db_data:
gem_cache:
shared_data:
tmp:
public:

0
public/exports/.keep Normal file
View file

0
public/imports/.keep Normal file
View file

10
spec/factories/exports.rb Normal file
View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
FactoryBot.define do
factory :export do
name { 'export' }
url { 'exports/export.json' }
status { 1 }
user
end
end

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ExportJob, type: :job do
let(:export) { create(:export) }
let(:start_at) { 1.day.ago }
let(:end_at) { Time.zone.now }
it 'calls the Exports::Create service class' do
expect(Exports::Create).to receive(:new).with(export:, start_at:, end_at:).and_call_original
described_class.perform_now(export.id, start_at, end_at)
end
end

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Export, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:user) }
end
describe 'enums' do
it { is_expected.to define_enum_for(:status).with_values(created: 0, processing: 1, completed: 2, failed: 3) }
end
end

View file

@ -8,6 +8,7 @@ RSpec.describe User, type: :model do
it { is_expected.to have_many(:points).through(:imports) }
it { is_expected.to have_many(:stats) }
it { is_expected.to have_many(:tracked_points).class_name('Point').dependent(:destroy) }
it { is_expected.to have_many(:exports).dependent(:destroy) }
end
describe 'callbacks' do
@ -23,19 +24,6 @@ RSpec.describe User, type: :model do
describe 'methods' do
let(:user) { create(:user) }
xdescribe '#export_data' do
subject { user.export_data }
let(:import) { create(:import, user:) }
let(:point) { create(:point, import:) }
it 'returns json' do
expect(subject).to include(user.email)
expect(subject).to include('dawarich-export')
expect(subject).to include(point.attributes.except('raw_data', 'id', 'created_at', 'updated_at', 'country', 'city', 'import_id').to_json)
end
end
describe '#countries_visited' do
subject { user.countries_visited }

View file

@ -1,17 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Exports', type: :request do
describe 'GET /download' do
before do
sign_in create(:user)
end
it 'returns http success' do
get '/export/download'
expect(response).to have_http_status(:success)
end
end
end

View file

@ -0,0 +1,98 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe '/exports', type: :request do
let(:user) { create(:user) }
let(:params) { { start_at: 1.day.ago, end_at: Time.zone.now } }
before do
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
end
describe 'GET /index' do
context 'when user is not logged in' do
it 'redirects to the login page' do
get exports_url
expect(response).to redirect_to(new_user_session_url)
end
end
context 'when user is logged in' do
before do
sign_in user
end
it 'renders a successful response' do
get exports_url
expect(response).to be_successful
end
end
end
describe 'POST /create' do
before { sign_in user }
context 'with valid parameters' do
let(:points) { create_list(:point, 10, user: user, timestamp: 1.day.ago) }
it 'creates a new Export' do
expect { post exports_url, params: params }.to change(Export, :count).by(1)
end
it 'redirects to the exports index page' do
post exports_url, params: params
expect(response).to redirect_to(exports_url)
end
it 'enqeuues a job to process the export' do
ActiveJob::Base.queue_adapter = :test
expect { post exports_url, params: params }.to have_enqueued_job(ExportJob)
end
end
context 'with invalid parameters' do
let(:params) { { start_at: nil, end_at: nil } }
it 'does not create a new Export' do
expect { post exports_url, params: params }.to change(Export, :count).by(0)
end
it 'renders a response with 422 status (i.e. to display the "new" template)' do
post exports_url, params: params
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe 'DELETE /destroy' do
let!(:export) { create(:export, user:, url: 'exports/export.json') }
before { sign_in user }
it 'destroys the requested export' do
expect { delete export_url(export) }.to change(Export, :count).by(-1)
end
it 'redirects to the exports list' do
delete export_url(export)
expect(response).to redirect_to(exports_url)
end
it 'remove the export file from the disk' do
export_file = Rails.root.join('public', export.url)
FileUtils.touch(export_file)
delete export_url(export)
expect(File.exist?(export_file)).to be_falsey
end
end
end

View file

@ -0,0 +1,43 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Exports::Create do
describe '#call' do
subject(:create_export) { described_class.new(export:, start_at:, end_at:).call }
let(:user) { create(:user) }
let(:start_at) { DateTime.new(2021, 1, 1) }
let(:end_at) { DateTime.new(2021, 1, 2) }
let(:export_name) { "#{start_at.to_date}_#{end_at.to_date}" }
let(:export) { create(:export, user:, name: export_name, status: :created) }
let(:export_content) { ExportSerializer.new(points, user.email).call }
let!(:points) { create_list(:point, 10, user:, timestamp: start_at.to_i) }
it 'writes the data to a file' do
create_export
file_path = Rails.root.join('public', 'exports', "#{export_name}.json")
expect(File.read(file_path)).to eq(export_content)
end
it 'updates the export url' do
create_export
expect(export.reload.url).to eq("exports/#{export.name}.json")
end
context 'when an error occurs' do
before do
allow(File).to receive(:open).and_raise(StandardError)
end
it 'updates the export status to failed' do
create_export
expect(export.reload.failed?).to be_truthy
end
end
end
end

View file

@ -180,7 +180,7 @@ paths:
lat: 52.502397
lon: 13.356718
tid: Swagger
tst: 1717877268
tst: 1718219019
servers:
- url: http://{defaultHost}
variables: