feat: add configuration management UI for fees, pairings, and application settings

This commit is contained in:
2026-06-02 16:07:30 +02:00
parent 107595826a
commit 815284289e
6 changed files with 397 additions and 33 deletions
+20 -30
View File
@@ -58,41 +58,34 @@ Recommended when you want Coolify to build from source and optionally auto-deplo
1. Open your Coolify project and select Create New Resource. 1. Open your Coolify project and select Create New Resource.
2. Choose deployment source: 2. Choose deployment source:
- Public repo: use `Public repository` and provide HTTPS URL.
- Public repo: use `Public repository` and provide HTTPS URL. - Private Gitea repo: use deploy key flow from the Gitea guide.
- Private Gitea repo: use deploy key flow from the Gitea guide.
3. Set repository URL for this project: 3. Set repository URL for this project:
- `https://git.allucanget.biz/allucanget/arbitrade.git` (public)
- `https://git.allucanget.biz/allucanget/arbitrade.git` (public) - or SSH URL if private deploy key is used.
- or SSH URL if private deploy key is used.
4. Choose build pack: 4. Choose build pack:
- Prefer `Dockerfile` for this repo, because Docker build behavior is explicitly defined.
- Prefer `Dockerfile` for this repo, because Docker build behavior is explicitly defined. - Use `Nixpacks` only if you intentionally want auto-detected build logic.
- Use `Nixpacks` only if you intentionally want auto-detected build logic.
5. Configure branch and base directory: 5. Configure branch and base directory:
- Branch: your deploy branch (for example `main`)
- Branch: your deploy branch (for example `main`) - Base directory: `/`
- Base directory: `/`
6. Configure network: 6. Configure network:
- Exposed port: `9090`
- Exposed port: `9090` - Domain: set your Coolify domain/custom domain
- Domain: set your Coolify domain/custom domain
7. Configure environment variables and secrets from the Common Runtime Configuration section. 7. Configure environment variables and secrets from the Common Runtime Configuration section.
8. Add persistent storage mount `/app/data`. 8. Add persistent storage mount `/app/data`.
9. Configure health check: 9. Configure health check:
- Path: `/health`
- Path: `/health` - Ensure container includes `curl` or `wget` if using UI-defined checks.
- Ensure container includes `curl` or `wget` if using UI-defined checks.
10. Click Deploy and verify: 10. Click Deploy and verify:
- Deployment logs complete successfully.
- Deployment logs complete successfully. - `GET /health` returns success.
- `GET /health` returns success.
Optional (Git webhook auto-deploy with Gitea): Optional (Git webhook auto-deploy with Gitea):
@@ -113,23 +106,20 @@ Image:
2. In Coolify, select Create New Resource. 2. In Coolify, select Create New Resource.
3. Choose Application deployment based on Docker Image. 3. Choose Application deployment based on Docker Image.
4. Set image reference: 4. Set image reference:
- Registry: `git.allucanget.biz`
- Registry: `git.allucanget.biz` - Image: `git.allucanget.biz/allucanget/arbitrade:latest`
- Image: `git.allucanget.biz/allucanget/arbitrade:latest`
5. Configure registry credentials in Coolify if your registry requires auth. 5. Configure registry credentials in Coolify if your registry requires auth.
6. Leave build/install/start commands empty unless you need overrides. 6. Leave build/install/start commands empty unless you need overrides.
7. Set network and health: 7. Set network and health:
- Exposed port: `9090`
- Exposed port: `9090` - Health check path: `/health`
- Health check path: `/health`
8. Add environment variables and secrets from the Common Runtime Configuration section. 8. Add environment variables and secrets from the Common Runtime Configuration section.
9. Add persistent storage mount `/app/data`. 9. Add persistent storage mount `/app/data`.
10. Deploy and verify: 10. Deploy and verify:
- Logs show container start success.
- Logs show container start success. - `GET /health` returns success.
- `GET /health` returns success.
Update flow for new releases: Update flow for new releases:
+51 -3
View File
@@ -64,6 +64,7 @@ class ConfigurationService:
self._audit_repo = audit_repo self._audit_repo = audit_repo
self._config_version = 0 self._config_version = 0
self._loaded_settings: dict[str, Any] = {} self._loaded_settings: dict[str, Any] = {}
self._last_updated_at: datetime | None = None
self._load_database_settings() self._load_database_settings()
def _load_database_settings(self) -> None: def _load_database_settings(self) -> None:
@@ -95,9 +96,11 @@ class ConfigurationService:
self._loaded_settings[setting.key] = parsed_value self._loaded_settings[setting.key] = parsed_value
# Merge with environment settings (environment takes precedence) # Track the latest update time
# This is a simplified approach - in reality we'd want to merge properly if db_settings:
# but for now we'll just load DB settings and let environment override them latest_updated = max(
setting.updated_at for setting in db_settings if setting.updated_at)
self._last_updated_at = latest_updated
# Initialize with default values from settings model # Initialize with default values from settings model
self._initialize_default_settings() self._initialize_default_settings()
@@ -112,6 +115,38 @@ class ConfigurationService:
"""Get a configuration setting value.""" """Get a configuration setting value."""
return self._loaded_settings.get(key, default) return self._loaded_settings.get(key, default)
def get_config_version(self) -> int:
"""Get the current configuration version for hot-reloading."""
return self._config_version
def get_last_updated_at(self) -> datetime | None:
"""Get the timestamp of the last configuration update."""
return self._last_updated_at
def is_config_outdated(self) -> bool:
"""Check if configuration has been updated since last load."""
# Import here to avoid circular imports
from arbitrade.storage.repositories import ConfigSettingRepository
setting_repo = ConfigSettingRepository(self._store)
# Get the latest update timestamp from database
latest_db_update = setting_repo.get_latest_updated_at()
# Compare with our last loaded timestamp
if latest_db_update and self._last_updated_at:
return latest_db_update > self._last_updated_at
elif latest_db_update:
return True
return False
def reload_if_changed(self) -> bool:
"""Reload configuration if it has been updated in the database."""
if self.is_config_outdated():
self._load_database_settings()
self._config_version += 1
return True
return False
def set_setting(self, key: str, value: Any, updated_by: str | None = None) -> None: def set_setting(self, key: str, value: Any, updated_by: str | None = None) -> None:
"""Set a configuration setting value and persist to database.""" """Set a configuration setting value and persist to database."""
# Import here to avoid circular imports # Import here to avoid circular imports
@@ -160,3 +195,16 @@ class ConfigurationService:
else: else:
# Create new setting # Create new setting
updated_setting = setting_repo.create_setting(setting) updated_setting = setting_repo.create_setting(setting)
# Update in-memory cache
self._loaded_settings[key] = value
# Update version for hot reloading
self._config_version += 1
# Update last updated timestamp
self._last_updated_at = updated_setting.updated_at
def get_all_settings(self) -> dict[str, Any]:
"""Get all configuration settings."""
return self._loaded_settings.copy()
+15
View File
@@ -590,6 +590,21 @@ class ConfigSettingRepository:
for row in cursor.fetchall() for row in cursor.fetchall()
] ]
def get_latest_updated_at(self) -> datetime | None:
"""Get the latest updated_at timestamp from config_settings table."""
with self._store.connect() as conn:
cursor = conn.execute(
"""
SELECT MAX(updated_at) as latest_updated_at
FROM config_settings
"""
)
row = cursor.fetchone()
if row and row[0]:
# Convert string timestamp to datetime
return datetime.fromisoformat(row[0].replace('Z', '+00:00'))
return None
class ConfigPairingRepository: class ConfigPairingRepository:
def __init__(self, store: DuckDBStore) -> None: def __init__(self, store: DuckDBStore) -> None:
@@ -0,0 +1,106 @@
{% extends "base.html" %} {% block title %}Fee Configuration{% endblock %} {%
block content %}
<div class="container mt-4">
<div class="row">
<div class="col-md-12">
<h2>Fee Configuration</h2>
{% if message %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ message }}
<button
type="button"
class="btn-close"
data-bs-dismiss="alert"
aria-label="Close"
></button>
</div>
{% endif %}
<div class="card">
<div class="card-header">
<h5>Configure Pairing Fees</h5>
</div>
<div class="card-body">
<form method="post" action="/dashboard/config/fees/save">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Pairing</th>
<th>Crypto/Crypto Maker</th>
<th>Crypto/Crypto Taker</th>
<th>Crypto/Fiat Maker</th>
<th>Crypto/Fiat Taker</th>
</tr>
</thead>
<tbody>
{% for pairing in pairings %}
<tr>
<td>
{{ pairing.base_asset }}/{{ pairing.quote_asset }}
<input
type="hidden"
name="pairing_id_{{ pairing.id }}"
value="{{ pairing.id }}"
/>
</td>
<td>
<input
type="number"
step="0.0001"
min="0"
max="0.05"
class="form-control"
name="maker_fee_{{ pairing.id }}"
placeholder="0.0010"
/>
</td>
<td>
<input
type="number"
step="0.0001"
min="0"
max="0.05"
class="form-control"
name="taker_fee_{{ pairing.id }}"
placeholder="0.0020"
/>
</td>
<td>
<input
type="number"
step="0.0001"
min="0"
max="0.05"
class="form-control"
name="maker_fee_{{ pairing.id }}_fiat"
placeholder="0.0010"
readonly
/>
</td>
<td>
<input
type="number"
step="0.0001"
min="0"
max="0.05"
class="form-control"
name="taker_fee_{{ pairing.id }}_fiat"
placeholder="0.0020"
readonly
/>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<button type="submit" class="btn btn-primary">Save Fees</button>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,153 @@
{% extends "base.html" %} {% block title %}Currency Pairings{% endblock %} {%
block content %}
<div class="container mt-4">
<div class="row">
<div class="col-md-12">
<h2>Currency Pairings</h2>
{% if message %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ message }}
<button
type="button"
class="btn-close"
data-bs-dismiss="alert"
aria-label="Close"
></button>
</div>
{% endif %}
<!-- Create Pairing Form -->
<div class="card mb-4">
<div class="card-header">
<h5>Create New Pairing</h5>
</div>
<div class="card-body">
<form method="post" action="/dashboard/config/pairs/create">
<div class="row">
<div class="col-md-4">
<div class="mb-3">
<label for="base_asset" class="form-label">Base Asset</label>
<input
type="text"
class="form-control"
id="base_asset"
name="base_asset"
required
/>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="quote_asset" class="form-label"
>Quote Asset</label
>
<input
type="text"
class="form-control"
id="quote_asset"
name="quote_asset"
required
/>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="source" class="form-label">Source</label>
<input
type="text"
class="form-control"
id="source"
name="source"
placeholder="e.g., Kraken, Binance"
/>
</div>
</div>
</div>
<div class="mb-3 form-check">
<input
type="checkbox"
class="form-check-input"
id="enabled"
name="enabled"
checked
/>
<label class="form-check-label" for="enabled">Enabled</label>
</div>
<button type="submit" class="btn btn-primary">
Create Pairing
</button>
</form>
</div>
</div>
<!-- Existing Pairings Table -->
<div class="card">
<div class="card-header">
<h5>Existing Pairings</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Base Asset</th>
<th>Quote Asset</th>
<th>Enabled</th>
<th>Source</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for pairing in pairings %}
<tr>
<td>{{ pairing.base_asset }}</td>
<td>{{ pairing.quote_asset }}</td>
<td>
{% if pairing.enabled %}
<span class="badge bg-success">Yes</span>
{% else %}
<span class="badge bg-secondary">No</span>
{% endif %}
</td>
<td>{{ pairing.source or '—' }}</td>
<td>
<form
method="post"
action="/dashboard/config/pairs/delete"
style="display: inline"
>
<input
type="hidden"
name="base_asset"
value="{{ pairing.base_asset }}"
/>
<input
type="hidden"
name="quote_asset"
value="{{ pairing.quote_asset }}"
/>
<button
type="submit"
class="btn btn-sm btn-danger"
onclick="
return confirm(
'Are you sure you want to delete this pairing?',
);
"
>
Delete
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,52 @@
{% extends "base.html" %} {% block title %}Application Settings{% endblock %} {%
block content %}
<div class="container mt-4">
<div class="row">
<div class="col-md-12">
<h2>Application Settings</h2>
{% if message %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ message }}
<button
type="button"
class="btn-close"
data-bs-dismiss="alert"
aria-label="Close"
></button>
</div>
{% endif %}
<div class="card">
<div class="card-header">
<h5>Configure Settings</h5>
</div>
<div class="card-body">
<form method="post" action="/dashboard/config/settings/save">
{% for section_name, settings in settings_by_section.items() %}
<div class="mb-4">
<h5>{{ section_name }}</h5>
{% for setting in settings %}
<div class="mb-3">
<label for="setting_{{ setting.key }}" class="form-label"
>{{ setting.key }}</label
>
<input
type="text"
class="form-control"
id="setting_{{ setting.key }}"
name="setting_{{ setting.key }}"
value="{{ setting.value_json }}"
/>
</div>
{% endfor %}
</div>
{% endfor %}
<button type="submit" class="btn btn-primary">Save Settings</button>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}