feat: odometer in trips/overview, trips upload toggle, fix video count
- OBD odometer now logged to tracking rows on every GPS snapshot - trips table: START_ODOMETER/END_ODOMETER columns (migration) - build_trip_summaries: picks odometer from first/last tracking row - list_trips: uses real OBD odo, falls back to cumulative distance - overview stats: odometer_km (max seen from OBD), video_count fixed - history page: Odometer column shows start→end km per trip - app_upload: TRIPS_UPLOAD_SETTING_KEY, is_trips_upload_enabled() - _upload_tracking_batch: skips when trips upload is off - settings: separate 'Upload trips & GPS' toggle from dashcam toggle Co-authored-by: Copilot <[email protected]>
This commit is contained in:
@@ -37,6 +37,7 @@ def _number_value(setting_key, converter):
|
|||||||
def get_settings():
|
def get_settings():
|
||||||
return {
|
return {
|
||||||
"dashcam_upload_enabled": app_upload.is_dashcam_upload_enabled(),
|
"dashcam_upload_enabled": app_upload.is_dashcam_upload_enabled(),
|
||||||
|
"trips_upload_enabled": app_upload.is_trips_upload_enabled(),
|
||||||
"local_video_retention_days": app_video.retention_days(),
|
"local_video_retention_days": app_video.retention_days(),
|
||||||
"video_convert_enabled": app_video.is_convert_enabled(),
|
"video_convert_enabled": app_video.is_convert_enabled(),
|
||||||
"cellular_enabled": _setting_bool(
|
"cellular_enabled": _setting_bool(
|
||||||
@@ -99,6 +100,7 @@ def update_settings(body):
|
|||||||
values = {}
|
values = {}
|
||||||
for field in (
|
for field in (
|
||||||
"dashcam_upload_enabled",
|
"dashcam_upload_enabled",
|
||||||
|
"trips_upload_enabled",
|
||||||
"video_convert_enabled",
|
"video_convert_enabled",
|
||||||
"cellular_enabled",
|
"cellular_enabled",
|
||||||
"ntfy_enabled",
|
"ntfy_enabled",
|
||||||
@@ -184,6 +186,9 @@ def update_settings(body):
|
|||||||
app_upload.DASHCAM_UPLOAD_SETTING_KEY: (
|
app_upload.DASHCAM_UPLOAD_SETTING_KEY: (
|
||||||
"1" if values["dashcam_upload_enabled"] else "0"
|
"1" if values["dashcam_upload_enabled"] else "0"
|
||||||
),
|
),
|
||||||
|
app_upload.TRIPS_UPLOAD_SETTING_KEY: (
|
||||||
|
"1" if values["trips_upload_enabled"] else "0"
|
||||||
|
),
|
||||||
app_video.RETENTION_SETTING: str(values["local_video_retention_days"]),
|
app_video.RETENTION_SETTING: str(values["local_video_retention_days"]),
|
||||||
app_video.CONVERT_SETTING: "1" if values["video_convert_enabled"] else "0",
|
app_video.CONVERT_SETTING: "1" if values["video_convert_enabled"] else "0",
|
||||||
"cellular_enabled": "1" if values["cellular_enabled"] else "0",
|
"cellular_enabled": "1" if values["cellular_enabled"] else "0",
|
||||||
|
|||||||
+48
-19
@@ -90,7 +90,9 @@ SQL_CREATE_TRIPS_TABLE = """CREATE TABLE IF NOT EXISTS trips (
|
|||||||
DURATION_SECONDS integer NOT NULL,
|
DURATION_SECONDS integer NOT NULL,
|
||||||
POINT_COUNT integer NOT NULL,
|
POINT_COUNT integer NOT NULL,
|
||||||
AVERAGE_SPEED_KMH float NOT NULL DEFAULT 0,
|
AVERAGE_SPEED_KMH float NOT NULL DEFAULT 0,
|
||||||
MAX_SPEED_KMH float NOT NULL DEFAULT 0
|
MAX_SPEED_KMH float NOT NULL DEFAULT 0,
|
||||||
|
START_ODOMETER float,
|
||||||
|
END_ODOMETER float
|
||||||
);"""
|
);"""
|
||||||
|
|
||||||
SQL_CREATE_TRIP_BUILD_STATE_TABLE = """CREATE TABLE IF NOT EXISTS trip_build_state (
|
SQL_CREATE_TRIP_BUILD_STATE_TABLE = """CREATE TABLE IF NOT EXISTS trip_build_state (
|
||||||
@@ -180,6 +182,8 @@ def _migrate_trips_table(conn):
|
|||||||
additions = {
|
additions = {
|
||||||
"AVERAGE_SPEED_KMH": "float NOT NULL DEFAULT 0",
|
"AVERAGE_SPEED_KMH": "float NOT NULL DEFAULT 0",
|
||||||
"MAX_SPEED_KMH": "float NOT NULL DEFAULT 0",
|
"MAX_SPEED_KMH": "float NOT NULL DEFAULT 0",
|
||||||
|
"START_ODOMETER": "float",
|
||||||
|
"END_ODOMETER": "float",
|
||||||
}
|
}
|
||||||
for name, definition in additions.items():
|
for name, definition in additions.items():
|
||||||
if name not in existing_columns:
|
if name not in existing_columns:
|
||||||
@@ -271,7 +275,7 @@ def refresh_trips(force=False):
|
|||||||
if intersecting_trip[0] is not None:
|
if intersecting_trip[0] is not None:
|
||||||
rebuild_start_id = min(rebuild_start_id, intersecting_trip[0])
|
rebuild_start_id = min(rebuild_start_id, intersecting_trip[0])
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""SELECT id, DATETIME, GPSX, GPSY, SPEED
|
"""SELECT id, DATETIME, GPSX, GPSY, SPEED, ODOMETER
|
||||||
FROM tracking WHERE id >= ? ORDER BY id ASC""",
|
FROM tracking WHERE id >= ? ORDER BY id ASC""",
|
||||||
(rebuild_start_id,),
|
(rebuild_start_id,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
@@ -289,13 +293,15 @@ def refresh_trips(force=False):
|
|||||||
START_DATETIME, END_DATETIME,
|
START_DATETIME, END_DATETIME,
|
||||||
START_GPSX, START_GPSY, END_GPSX, END_GPSY,
|
START_GPSX, START_GPSY, END_GPSX, END_GPSY,
|
||||||
DISTANCE_KM, DURATION_SECONDS, POINT_COUNT,
|
DISTANCE_KM, DURATION_SECONDS, POINT_COUNT,
|
||||||
AVERAGE_SPEED_KMH, MAX_SPEED_KMH
|
AVERAGE_SPEED_KMH, MAX_SPEED_KMH,
|
||||||
|
START_ODOMETER, END_ODOMETER
|
||||||
) VALUES (
|
) VALUES (
|
||||||
:id, :start_tracking_id, :end_tracking_id,
|
:id, :start_tracking_id, :end_tracking_id,
|
||||||
:start_datetime, :end_datetime,
|
:start_datetime, :end_datetime,
|
||||||
:start_gpsx, :start_gpsy, :end_gpsx, :end_gpsy,
|
:start_gpsx, :start_gpsy, :end_gpsx, :end_gpsy,
|
||||||
:distance_km, :duration_seconds, :point_count,
|
:distance_km, :duration_seconds, :point_count,
|
||||||
:average_speed_kmh, :max_speed_kmh
|
:average_speed_kmh, :max_speed_kmh,
|
||||||
|
:start_odometer, :end_odometer
|
||||||
)""",
|
)""",
|
||||||
trips,
|
trips,
|
||||||
)
|
)
|
||||||
@@ -325,15 +331,25 @@ def get_overview_stats():
|
|||||||
COALESCE(SUM(DURATION_SECONDS),0), COALESCE(MAX(MAX_SPEED_KMH),0)
|
COALESCE(SUM(DURATION_SECONDS),0), COALESCE(MAX(MAX_SPEED_KMH),0)
|
||||||
FROM trips
|
FROM trips
|
||||||
""").fetchone()
|
""").fetchone()
|
||||||
|
odo_row = conn.execute("""
|
||||||
|
SELECT MAX(ODOMETER) FROM tracking WHERE ODOMETER IS NOT NULL AND ODOMETER > 0
|
||||||
|
""").fetchone()
|
||||||
dtc_row = conn.execute("""
|
dtc_row = conn.execute("""
|
||||||
SELECT COUNT(*) FROM dtc_codes WHERE cleared_at IS NULL
|
SELECT COUNT(*) FROM dtc_codes WHERE cleared_at IS NULL
|
||||||
""").fetchone() if _table_exists(conn, "dtc_codes") else (0,)
|
""").fetchone() if _table_exists(conn, "dtc_codes") else (0,)
|
||||||
|
from modules import app_video as _av
|
||||||
|
try:
|
||||||
|
video_count = sum(1 for _ in _av.list_local_videos().get("files", []))
|
||||||
|
except Exception:
|
||||||
|
video_count = 0
|
||||||
return {
|
return {
|
||||||
"trip_count": row[0],
|
"trip_count": row[0],
|
||||||
"total_km": row[1],
|
"total_km": row[1],
|
||||||
"total_duration_s": row[2],
|
"total_duration_s": row[2],
|
||||||
"all_time_max_speed": row[3],
|
"all_time_max_speed": row[3],
|
||||||
"active_dtc": dtc_row[0] if dtc_row else 0,
|
"active_dtc": dtc_row[0] if dtc_row else 0,
|
||||||
|
"video_count": video_count,
|
||||||
|
"odometer_km": odo_row[0] if odo_row and odo_row[0] else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -353,21 +369,34 @@ def list_trips():
|
|||||||
"""SELECT id, START_DATETIME, END_DATETIME,
|
"""SELECT id, START_DATETIME, END_DATETIME,
|
||||||
START_GPSX, START_GPSY, END_GPSX, END_GPSY,
|
START_GPSX, START_GPSY, END_GPSX, END_GPSY,
|
||||||
DISTANCE_KM, DURATION_SECONDS, POINT_COUNT,
|
DISTANCE_KM, DURATION_SECONDS, POINT_COUNT,
|
||||||
AVERAGE_SPEED_KMH, MAX_SPEED_KMH
|
AVERAGE_SPEED_KMH, MAX_SPEED_KMH,
|
||||||
FROM trips ORDER BY START_DATETIME DESC"""
|
START_ODOMETER, END_ODOMETER
|
||||||
|
FROM trips ORDER BY START_DATETIME ASC"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [{
|
# If real OBD odometer is available use it; otherwise fall back to cumulative distance
|
||||||
"id": row[0],
|
result = []
|
||||||
"start_datetime": row[1],
|
cumulative_km = 0.0
|
||||||
"end_datetime": row[2],
|
for row in rows:
|
||||||
"start": {"latitude": row[3], "longitude": row[4]},
|
dist = row[7] or 0
|
||||||
"end": {"latitude": row[5], "longitude": row[6]},
|
start_odo = row[12] if row[12] is not None else round(cumulative_km, 1)
|
||||||
"distance_km": row[7],
|
end_odo = row[13] if row[13] is not None else round(cumulative_km + dist, 1)
|
||||||
"duration_seconds": row[8],
|
cumulative_km += dist
|
||||||
"point_count": row[9],
|
result.append({
|
||||||
"average_speed_kmh": row[10],
|
"id": row[0],
|
||||||
"max_speed_kmh": row[11],
|
"start_datetime": row[1],
|
||||||
} for row in rows]
|
"end_datetime": row[2],
|
||||||
|
"start": {"latitude": row[3], "longitude": row[4]},
|
||||||
|
"end": {"latitude": row[5], "longitude": row[6]},
|
||||||
|
"distance_km": row[7],
|
||||||
|
"duration_seconds": row[8],
|
||||||
|
"point_count": row[9],
|
||||||
|
"average_speed_kmh": row[10],
|
||||||
|
"max_speed_kmh": row[11],
|
||||||
|
"odo_start_km": start_odo,
|
||||||
|
"odo_end_km": end_odo,
|
||||||
|
})
|
||||||
|
result.reverse() # newest first for display
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def get_trip_route(trip_id, max_points=2000):
|
def get_trip_route(trip_id, max_points=2000):
|
||||||
@@ -674,7 +703,7 @@ def _log_loop():
|
|||||||
)
|
)
|
||||||
if should_log_tracking(
|
if should_log_tracking(
|
||||||
position, speed, last_position, seconds_since_log
|
position, speed, last_position, seconds_since_log
|
||||||
) and insert_tracking(position[0], position[1], speed):
|
) and insert_tracking(position[0], position[1], speed, app_obd.odometer):
|
||||||
last_position = position
|
last_position = position
|
||||||
last_logged_at = now
|
last_logged_at = now
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ def normalize_tracking_rows(rows):
|
|||||||
latitude = _number(row[2])
|
latitude = _number(row[2])
|
||||||
longitude = _number(row[3])
|
longitude = _number(row[3])
|
||||||
speed = _number(row[4]) or 0
|
speed = _number(row[4]) or 0
|
||||||
|
odometer = _number(row[5]) if len(row) > 5 else None
|
||||||
if timestamp is None or latitude is None or longitude is None:
|
if timestamp is None or latitude is None or longitude is None:
|
||||||
continue
|
continue
|
||||||
if not (-90 <= latitude <= 90 and -180 <= longitude <= 180):
|
if not (-90 <= latitude <= 90 and -180 <= longitude <= 180):
|
||||||
@@ -56,6 +57,7 @@ def normalize_tracking_rows(rows):
|
|||||||
"latitude": latitude,
|
"latitude": latitude,
|
||||||
"longitude": longitude,
|
"longitude": longitude,
|
||||||
"speed": max(0, speed),
|
"speed": max(0, speed),
|
||||||
|
"odometer": odometer,
|
||||||
})
|
})
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
@@ -202,6 +204,8 @@ def build_trip_summaries(raw_rows):
|
|||||||
"max_speed_kmh": round(
|
"max_speed_kmh": round(
|
||||||
max(obd_speeds or segment_speeds or [0]), 1
|
max(obd_speeds or segment_speeds or [0]), 1
|
||||||
),
|
),
|
||||||
|
"start_odometer": next((r["odometer"] for r in trip_rows if r.get("odometer")), None),
|
||||||
|
"end_odometer": next((r["odometer"] for r in reversed(trip_rows) if r.get("odometer")), None),
|
||||||
})
|
})
|
||||||
return trips
|
return trips
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ BACKOFF_MAX = 300
|
|||||||
DASHCAM_CHUNK_SIZE = 40 * 1024 * 1024
|
DASHCAM_CHUNK_SIZE = 40 * 1024 * 1024
|
||||||
|
|
||||||
DASHCAM_UPLOAD_SETTING_KEY = "dashcam_upload_enabled"
|
DASHCAM_UPLOAD_SETTING_KEY = "dashcam_upload_enabled"
|
||||||
|
TRIPS_UPLOAD_SETTING_KEY = "trips_upload_enabled"
|
||||||
|
|
||||||
_upload_thread = None
|
_upload_thread = None
|
||||||
_last_error = None
|
_last_error = None
|
||||||
@@ -90,6 +91,11 @@ def set_dashcam_upload_enabled(enabled):
|
|||||||
app_sql.set_setting(DASHCAM_UPLOAD_SETTING_KEY, "1" if enabled else "0")
|
app_sql.set_setting(DASHCAM_UPLOAD_SETTING_KEY, "1" if enabled else "0")
|
||||||
|
|
||||||
|
|
||||||
|
def is_trips_upload_enabled():
|
||||||
|
"""Defaults to enabled."""
|
||||||
|
return app_sql.get_setting(TRIPS_UPLOAD_SETTING_KEY, "1") == "1"
|
||||||
|
|
||||||
|
|
||||||
def get_status():
|
def get_status():
|
||||||
"""Snapshot used by the /api/upload/status endpoint for the web UI."""
|
"""Snapshot used by the /api/upload/status endpoint for the web UI."""
|
||||||
return {
|
return {
|
||||||
@@ -138,6 +144,8 @@ def _upload_tracking_batch():
|
|||||||
modem's AT-command HTTP client before giving up.
|
modem's AT-command HTTP client before giving up.
|
||||||
"""
|
"""
|
||||||
global _last_network_path
|
global _last_network_path
|
||||||
|
if not is_trips_upload_enabled():
|
||||||
|
return 0
|
||||||
|
|
||||||
rows = app_sql.get_unsent_tracking_rows(limit=TRACKING_BATCH_SIZE)
|
rows = app_sql.get_unsent_tracking_rows(limit=TRACKING_BATCH_SIZE)
|
||||||
if not rows:
|
if not rows:
|
||||||
|
|||||||
@@ -156,6 +156,7 @@
|
|||||||
<td>${formatDistance(trip.distance_km)}</td>
|
<td>${formatDistance(trip.distance_km)}</td>
|
||||||
<td>${formatDuration(trip.duration_seconds)}</td>
|
<td>${formatDuration(trip.duration_seconds)}</td>
|
||||||
<td>${formatSpeed(trip.max_speed_kmh)}</td>
|
<td>${formatSpeed(trip.max_speed_kmh)}</td>
|
||||||
|
<td class="text-muted small">${trip.odo_start_km.toFixed(0)} → ${trip.odo_end_km.toFixed(0)} km</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<button class="btn btn-sm btn-primary" type="button" data-trip-id="${trip.id}">
|
<button class="btn btn-sm btn-primary" type="button" data-trip-id="${trip.id}">
|
||||||
Replay
|
Replay
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const lastPhoneAddressLink = document.getElementById('last-phone-address-link');
|
|||||||
let initialUplinkStatusLoaded = false;
|
let initialUplinkStatusLoaded = false;
|
||||||
|
|
||||||
const fields = {
|
const fields = {
|
||||||
|
tripsUpload: document.getElementById('trips-upload-enabled'),
|
||||||
dashcamUpload: document.getElementById('dashcam-upload-enabled'),
|
dashcamUpload: document.getElementById('dashcam-upload-enabled'),
|
||||||
retention: document.getElementById('retention-days'),
|
retention: document.getElementById('retention-days'),
|
||||||
videoConvert: document.getElementById('video-convert-enabled'),
|
videoConvert: document.getElementById('video-convert-enabled'),
|
||||||
@@ -79,6 +80,7 @@ function applyErrors(errors) {
|
|||||||
|
|
||||||
function populate(settings) {
|
function populate(settings) {
|
||||||
const thresholds = settings.alert_thresholds;
|
const thresholds = settings.alert_thresholds;
|
||||||
|
fields.tripsUpload.checked = settings.trips_upload_enabled;
|
||||||
fields.dashcamUpload.checked = settings.dashcam_upload_enabled;
|
fields.dashcamUpload.checked = settings.dashcam_upload_enabled;
|
||||||
fields.retention.value = settings.local_video_retention_days;
|
fields.retention.value = settings.local_video_retention_days;
|
||||||
fields.videoConvert.checked = settings.video_convert_enabled;
|
fields.videoConvert.checked = settings.video_convert_enabled;
|
||||||
@@ -105,6 +107,7 @@ function populate(settings) {
|
|||||||
|
|
||||||
function payload() {
|
function payload() {
|
||||||
return {
|
return {
|
||||||
|
trips_upload_enabled: fields.tripsUpload.checked,
|
||||||
dashcam_upload_enabled: fields.dashcamUpload.checked,
|
dashcam_upload_enabled: fields.dashcamUpload.checked,
|
||||||
local_video_retention_days: Number(fields.retention.value),
|
local_video_retention_days: Number(fields.retention.value),
|
||||||
video_convert_enabled: fields.videoConvert.checked,
|
video_convert_enabled: fields.videoConvert.checked,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
{"key": "distance", "label": "km", "sortable": true},
|
{"key": "distance", "label": "km", "sortable": true},
|
||||||
{"key": "duration", "label": "Time", "sortable": true},
|
{"key": "duration", "label": "Time", "sortable": true},
|
||||||
{"key": "maxSpeed", "label": "Max", "sortable": true},
|
{"key": "maxSpeed", "label": "Max", "sortable": true},
|
||||||
|
{"key": "odometer", "label": "Odometer", "sortable": false},
|
||||||
{"key": "action", "label": "", "sortable": false}
|
{"key": "action", "label": "", "sortable": false}
|
||||||
],
|
],
|
||||||
"No trips detected",
|
"No trips detected",
|
||||||
|
|||||||
@@ -136,6 +136,10 @@
|
|||||||
<div class="text-xs text-muted fw-bold text-uppercase mb-1">Top speed ever</div>
|
<div class="text-xs text-muted fw-bold text-uppercase mb-1">Top speed ever</div>
|
||||||
<div class="fw-bold fs-5 text-dark" id="stat-topspeed">—</div>
|
<div class="fw-bold fs-5 text-dark" id="stat-topspeed">—</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-6 col-lg-12">
|
||||||
|
<div class="text-xs text-muted fw-bold text-uppercase mb-1">Odometer</div>
|
||||||
|
<div class="fw-bold fs-5 text-dark" id="stat-odometer">—</div>
|
||||||
|
</div>
|
||||||
<div class="col-6 col-lg-12">
|
<div class="col-6 col-lg-12">
|
||||||
<div class="text-xs text-muted fw-bold text-uppercase mb-1">Videos on Pi</div>
|
<div class="text-xs text-muted fw-bold text-uppercase mb-1">Videos on Pi</div>
|
||||||
<div class="fw-bold fs-5 text-dark" id="stat-videos">—</div>
|
<div class="fw-bold fs-5 text-dark" id="stat-videos">—</div>
|
||||||
@@ -218,6 +222,7 @@
|
|||||||
document.getElementById('stat-km').textContent = d.total_km.toFixed(1) + ' km';
|
document.getElementById('stat-km').textContent = d.total_km.toFixed(1) + ' km';
|
||||||
document.getElementById('stat-time').textContent = d.total_duration_s > 0 ? fmtDuration(d.total_duration_s) : '—';
|
document.getElementById('stat-time').textContent = d.total_duration_s > 0 ? fmtDuration(d.total_duration_s) : '—';
|
||||||
document.getElementById('stat-topspeed').textContent = d.all_time_max_speed > 0 ? d.all_time_max_speed + ' km/h' : '—';
|
document.getElementById('stat-topspeed').textContent = d.all_time_max_speed > 0 ? d.all_time_max_speed + ' km/h' : '—';
|
||||||
|
document.getElementById('stat-odometer').textContent = d.odometer_km != null ? Math.round(d.odometer_km).toLocaleString() + ' km' : '—';
|
||||||
document.getElementById('stat-videos').textContent = d.video_count;
|
document.getElementById('stat-videos').textContent = d.video_count;
|
||||||
const dtcEl = document.getElementById('stat-dtc');
|
const dtcEl = document.getElementById('stat-dtc');
|
||||||
dtcEl.textContent = d.active_dtc;
|
dtcEl.textContent = d.active_dtc;
|
||||||
|
|||||||
@@ -25,10 +25,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-body py-3">
|
<div class="card-body py-3">
|
||||||
<div class="form-check form-switch mb-2">
|
<div class="form-check form-switch mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="trips-upload-enabled">
|
||||||
|
<label class="form-check-label" for="trips-upload-enabled">Upload trips & GPS logs to server</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch mb-2">
|
||||||
<input class="form-check-input" type="checkbox" role="switch" id="dashcam-upload-enabled">
|
<input class="form-check-input" type="checkbox" role="switch" id="dashcam-upload-enabled">
|
||||||
<label class="form-check-label" for="dashcam-upload-enabled">Upload dashcam videos to server</label>
|
<label class="form-check-label" for="dashcam-upload-enabled">Upload dashcam videos to server (WiFi only)</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted small mb-2">Trip/GPS always uploads. Dashcam video only over WiFi/Ethernet.</p>
|
|
||||||
<p id="upload-health" class="small text-muted mb-3">Checking…</p>
|
<p id="upload-health" class="small text-muted mb-3">Checking…</p>
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<label class="form-label mb-0 text-nowrap" for="retention-days">Delete after</label>
|
<label class="form-label mb-0 text-nowrap" for="retention-days">Delete after</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user