mirror of
https://github.com/EFForg/rayhunter.git
synced 2026-06-05 04:31:53 -07:00
GPS feature webapp side: GPS mode selector, fixed mode lat/lon, API endpoint. Merging with Wifi client and webdav features
This commit is contained in:
committed by
Will Greenberg
parent
ac33ebaf53
commit
c107314194
@@ -36,6 +36,12 @@ pub struct Config {
|
|||||||
pub min_space_to_start_recording_mb: u64,
|
pub min_space_to_start_recording_mb: u64,
|
||||||
/// Minimum disk space required to continue a recording
|
/// Minimum disk space required to continue a recording
|
||||||
pub min_space_to_continue_recording_mb: u64,
|
pub min_space_to_continue_recording_mb: u64,
|
||||||
|
/// GPS mode: 0=Disabled, 1=Fixed coordinates, 2=API endpoint
|
||||||
|
pub gps_mode: u8,
|
||||||
|
/// Fixed latitude used when gps_mode=1
|
||||||
|
pub gps_fixed_latitude: Option<f64>,
|
||||||
|
/// Fixed longitude used when gps_mode=1
|
||||||
|
pub gps_fixed_longitude: Option<f64>,
|
||||||
/// Wifi client SSID
|
/// Wifi client SSID
|
||||||
pub wifi_ssid: Option<String>,
|
pub wifi_ssid: Option<String>,
|
||||||
/// Wifi client password
|
/// Wifi client password
|
||||||
@@ -100,6 +106,9 @@ impl Default for Config {
|
|||||||
enabled_notifications: vec![NotificationType::Warning, NotificationType::LowBattery],
|
enabled_notifications: vec![NotificationType::Warning, NotificationType::LowBattery],
|
||||||
min_space_to_start_recording_mb: 1,
|
min_space_to_start_recording_mb: 1,
|
||||||
min_space_to_continue_recording_mb: 1,
|
min_space_to_continue_recording_mb: 1,
|
||||||
|
gps_mode: 0,
|
||||||
|
gps_fixed_latitude: None,
|
||||||
|
gps_fixed_longitude: None,
|
||||||
wifi_ssid: None,
|
wifi_ssid: None,
|
||||||
wifi_password: None,
|
wifi_password: None,
|
||||||
wifi_security: None,
|
wifi_security: None,
|
||||||
@@ -153,6 +162,49 @@ fn resolve_bin(name: &str) -> Option<String> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn wifi_config(&self) -> wifi_station::WifiConfig {
|
||||||
|
let (wpa_bin, hostapd_conf, ctrl_interface) = match self.device {
|
||||||
|
Device::Tmobile | Device::Wingtech => (
|
||||||
|
Some("/usr/sbin/wpa_supplicant".into()),
|
||||||
|
Some("/data/configs/hostapd.conf".into()),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
Device::Uz801 => (
|
||||||
|
Some("/system/bin/wpa_supplicant".into()),
|
||||||
|
Some("/data/misc/wifi/hostapd.conf".into()),
|
||||||
|
Some("/data/misc/wifi/sockets".into()),
|
||||||
|
),
|
||||||
|
_ => (None, None, None),
|
||||||
|
};
|
||||||
|
wifi_station::WifiConfig {
|
||||||
|
wifi_enabled: self.wifi_enabled,
|
||||||
|
dns_servers: self.dns_servers.clone(),
|
||||||
|
wifi_ssid: self.wifi_ssid.clone(),
|
||||||
|
wifi_password: self.wifi_password.clone(),
|
||||||
|
security_type: self.wifi_security,
|
||||||
|
wpa_supplicant_bin: wpa_bin.or_else(|| resolve_bin("wpa_supplicant")),
|
||||||
|
hostapd_conf,
|
||||||
|
ctrl_interface,
|
||||||
|
udhcpc_hook_path: Some("/data/rayhunter/udhcpc-hook.sh".into()),
|
||||||
|
dhcp_lease_path: Some("/data/rayhunter/dhcp_lease".into()),
|
||||||
|
wpa_conf_path: Some("/data/rayhunter/wpa_sta.conf".into()),
|
||||||
|
iw_bin: resolve_bin("iw"),
|
||||||
|
udhcpc_bin: resolve_bin("udhcpc"),
|
||||||
|
crash_log_dir: Some("/data/rayhunter/crash-logs".into()),
|
||||||
|
wakelock_name: Some("rayhunter".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_bin(name: &str) -> Option<String> {
|
||||||
|
let local = format!("/data/rayhunter/bin/{name}");
|
||||||
|
if std::path::Path::new(&local).exists() {
|
||||||
|
return Some(local);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn parse_config<P>(path: P) -> Result<Config, RayhunterError>
|
pub async fn parse_config<P>(path: P) -> Result<Config, RayhunterError>
|
||||||
where
|
where
|
||||||
P: AsRef<std::path::Path>,
|
P: AsRef<std::path::Path>,
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::server::ServerState;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct GpsData {
|
||||||
|
pub latitude: f64,
|
||||||
|
pub longitude: f64,
|
||||||
|
pub timestamp: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_gps(
|
||||||
|
State(state): State<Arc<ServerState>>,
|
||||||
|
Json(gps_data): Json<GpsData>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
if state.config.gps_mode != 2 {
|
||||||
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"GPS API endpoint is disabled. Set gps_mode to 2 in configuration.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut gps = state.gps_state.write().await;
|
||||||
|
*gps = Some(gps_data);
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_gps(
|
||||||
|
State(state): State<Arc<ServerState>>,
|
||||||
|
) -> Result<Json<GpsData>, StatusCode> {
|
||||||
|
let gps = state.gps_state.read().await;
|
||||||
|
match gps.as_ref() {
|
||||||
|
Some(data) => Ok(Json(data.clone())),
|
||||||
|
None => Err(StatusCode::NOT_FOUND),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ pub mod crypto_provider;
|
|||||||
pub mod diag;
|
pub mod diag;
|
||||||
pub mod display;
|
pub mod display;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod gps;
|
||||||
pub mod key_input;
|
pub mod key_input;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod pcap;
|
pub mod pcap;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ mod crypto_provider;
|
|||||||
mod diag;
|
mod diag;
|
||||||
mod display;
|
mod display;
|
||||||
mod error;
|
mod error;
|
||||||
|
mod gps;
|
||||||
mod key_input;
|
mod key_input;
|
||||||
mod notifications;
|
mod notifications;
|
||||||
mod pcap;
|
mod pcap;
|
||||||
@@ -23,6 +24,7 @@ use crate::error::RayhunterError;
|
|||||||
use crate::notifications::{NotificationService, run_notification_worker};
|
use crate::notifications::{NotificationService, run_notification_worker};
|
||||||
use crate::pcap::get_pcap;
|
use crate::pcap::get_pcap;
|
||||||
use crate::qmdl_store::RecordingStore;
|
use crate::qmdl_store::RecordingStore;
|
||||||
|
use crate::gps::{get_gps, post_gps};
|
||||||
use crate::server::{
|
use crate::server::{
|
||||||
ServerState, debug_set_display_state, get_config, get_qmdl, get_time, get_wifi_status, get_zip,
|
ServerState, debug_set_display_state, get_config, get_qmdl, get_time, get_wifi_status, get_zip,
|
||||||
scan_wifi, serve_static, set_config, set_time_offset, test_notification,
|
scan_wifi, serve_static, set_config, set_time_offset, test_notification,
|
||||||
@@ -78,6 +80,8 @@ fn get_router() -> AppRouter {
|
|||||||
.route("/api/time", get(get_time))
|
.route("/api/time", get(get_time))
|
||||||
.route("/api/time-offset", post(set_time_offset))
|
.route("/api/time-offset", post(set_time_offset))
|
||||||
.route("/api/debug/display-state", post(debug_set_display_state))
|
.route("/api/debug/display-state", post(debug_set_display_state))
|
||||||
|
.route("/api/gps", get(get_gps))
|
||||||
|
.route("/api/gps", post(post_gps))
|
||||||
.route("/", get(|| async { Redirect::permanent("/index.html") }))
|
.route("/", get(|| async { Redirect::permanent("/index.html") }))
|
||||||
.route("/{*path}", get(serve_static))
|
.route("/{*path}", get(serve_static))
|
||||||
}
|
}
|
||||||
@@ -296,6 +300,18 @@ async fn run_with_config(
|
|||||||
config.webdav.clone().into(),
|
config.webdav.clone().into(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
let initial_gps = if config.gps_mode == 1 {
|
||||||
|
match (config.gps_fixed_latitude, config.gps_fixed_longitude) {
|
||||||
|
(Some(lat), Some(lon)) => Some(gps::GpsData {
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon,
|
||||||
|
timestamp: "fixed".to_string(),
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let state = Arc::new(ServerState {
|
let state = Arc::new(ServerState {
|
||||||
config_path: args.config_path.clone(),
|
config_path: args.config_path.clone(),
|
||||||
@@ -308,6 +324,7 @@ async fn run_with_config(
|
|||||||
ui_update_sender: Some(ui_update_tx),
|
ui_update_sender: Some(ui_update_tx),
|
||||||
wifi_status,
|
wifi_status,
|
||||||
wifi_scan_lock: tokio::sync::Mutex::new(()),
|
wifi_scan_lock: tokio::sync::Mutex::new(()),
|
||||||
|
gps_state: Arc::new(tokio::sync::RwLock::new(initial_gps)),
|
||||||
});
|
});
|
||||||
run_server(&task_tracker, state, shutdown_token.clone()).await;
|
run_server(&task_tracker, state, shutdown_token.clone()).await;
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ use crate::config::Config;
|
|||||||
use crate::diag::DiagDeviceCtrlMessage;
|
use crate::diag::DiagDeviceCtrlMessage;
|
||||||
use crate::display::DisplayState;
|
use crate::display::DisplayState;
|
||||||
use crate::notifications::DEFAULT_NOTIFICATION_TIMEOUT;
|
use crate::notifications::DEFAULT_NOTIFICATION_TIMEOUT;
|
||||||
|
use crate::gps::GpsData;
|
||||||
use crate::pcap::generate_pcap_data;
|
use crate::pcap::generate_pcap_data;
|
||||||
use crate::qmdl_store::RecordingStore;
|
use crate::qmdl_store::RecordingStore;
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ pub struct ServerState {
|
|||||||
pub ui_update_sender: Option<Sender<DisplayState>>,
|
pub ui_update_sender: Option<Sender<DisplayState>>,
|
||||||
pub wifi_status: Arc<RwLock<wifi_station::WifiStatus>>,
|
pub wifi_status: Arc<RwLock<wifi_station::WifiStatus>>,
|
||||||
pub wifi_scan_lock: tokio::sync::Mutex<()>,
|
pub wifi_scan_lock: tokio::sync::Mutex<()>,
|
||||||
|
pub gps_state: Arc<RwLock<Option<GpsData>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(feature = "apidocs", utoipa::path(
|
#[cfg_attr(feature = "apidocs", utoipa::path(
|
||||||
@@ -566,6 +568,7 @@ mod tests {
|
|||||||
ui_update_sender: None,
|
ui_update_sender: None,
|
||||||
wifi_status: Arc::new(RwLock::new(wifi_station::WifiStatus::default())),
|
wifi_status: Arc::new(RwLock::new(wifi_station::WifiStatus::default())),
|
||||||
wifi_scan_lock: tokio::sync::Mutex::new(()),
|
wifi_scan_lock: tokio::sync::Mutex::new(()),
|
||||||
|
gps_state: Arc::new(RwLock::new(None)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -781,6 +781,79 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t pt-4 mt-6 space-y-3">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800 mb-4">GPS Settings</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="gps_mode" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
GPS Mode
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="gps_mode"
|
||||||
|
bind:value={config.gps_mode}
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
|
||||||
|
>
|
||||||
|
<option value={0}>0 - Disabled</option>
|
||||||
|
<option value={1}>1 - Fixed coordinates</option>
|
||||||
|
<option value={2}>2 - API Endpoint</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
{#if config.gps_mode === 2}
|
||||||
|
POST latitude, longitude, and timestamp to <code>/api/gps</code> from
|
||||||
|
any device on the network.
|
||||||
|
{:else if config.gps_mode === 1}
|
||||||
|
GPS coordinates are fixed to the values below.
|
||||||
|
{:else}
|
||||||
|
GPS is disabled; no coordinates will be tracked.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if config.gps_mode === 1}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="gps_fixed_latitude"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Fixed Latitude
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="gps_fixed_latitude"
|
||||||
|
type="number"
|
||||||
|
min="-90"
|
||||||
|
max="90"
|
||||||
|
step="any"
|
||||||
|
required
|
||||||
|
bind:value={config.gps_fixed_latitude}
|
||||||
|
placeholder="e.g. 37.7749"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Decimal degrees, -90 to 90</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="gps_fixed_longitude"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Fixed Longitude
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="gps_fixed_longitude"
|
||||||
|
type="number"
|
||||||
|
min="-180"
|
||||||
|
max="180"
|
||||||
|
step="any"
|
||||||
|
required
|
||||||
|
bind:value={config.gps_fixed_longitude}
|
||||||
|
placeholder="e.g. -122.4194"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Decimal degrees, -180 to 180</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2 pt-4">
|
<div class="flex gap-2 pt-4">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ export interface Config {
|
|||||||
firewall_restrict_outbound: boolean;
|
firewall_restrict_outbound: boolean;
|
||||||
firewall_allowed_ports: number[] | null;
|
firewall_allowed_ports: number[] | null;
|
||||||
webdav: WebdavConfig;
|
webdav: WebdavConfig;
|
||||||
|
gps_mode: number;
|
||||||
|
gps_fixed_latitude: number | null;
|
||||||
|
gps_fixed_longitude: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WifiStatus {
|
export interface WifiStatus {
|
||||||
@@ -153,3 +156,20 @@ export interface TimeResponse {
|
|||||||
export async function get_daemon_time(): Promise<TimeResponse> {
|
export async function get_daemon_time(): Promise<TimeResponse> {
|
||||||
return JSON.parse(await req('GET', '/api/time'));
|
return JSON.parse(await req('GET', '/api/time'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GpsData {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get_gps(): Promise<GpsData | null> {
|
||||||
|
const response = await fetch('/api/gps');
|
||||||
|
if (response.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (response.status >= 200 && response.status < 300) {
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ManifestEntry } from '$lib/manifest.svelte';
|
import { ManifestEntry } from '$lib/manifest.svelte';
|
||||||
import { get_manifest, get_system_stats } from '$lib/utils.svelte';
|
import { get_manifest, get_system_stats, get_gps, get_config, type GpsData } from '$lib/utils.svelte';
|
||||||
import ManifestTable from '$lib/components/ManifestTable.svelte';
|
import ManifestTable from '$lib/components/ManifestTable.svelte';
|
||||||
import Card from '$lib/components/ManifestCard.svelte';
|
import Card from '$lib/components/ManifestCard.svelte';
|
||||||
import type { SystemStats } from '$lib/systemStats';
|
import type { SystemStats } from '$lib/systemStats';
|
||||||
@@ -22,7 +22,13 @@
|
|||||||
let update_error: string | undefined = $state(undefined);
|
let update_error: string | undefined = $state(undefined);
|
||||||
let logview_shown: boolean = $state(false);
|
let logview_shown: boolean = $state(false);
|
||||||
let config_shown: boolean = $state(false);
|
let config_shown: boolean = $state(false);
|
||||||
|
let gps_data: GpsData | null = $state(null);
|
||||||
|
let gps_mode: number = $state(0);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
get_config().then((c) => {
|
||||||
|
gps_mode = c.gps_mode;
|
||||||
|
});
|
||||||
|
|
||||||
const interval = setInterval(async () => {
|
const interval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
// Don't update UI if browser tab isn't visible
|
// Don't update UI if browser tab isn't visible
|
||||||
@@ -40,6 +46,7 @@
|
|||||||
current_entry = new_manifest.current_entry;
|
current_entry = new_manifest.current_entry;
|
||||||
|
|
||||||
system_stats = await get_system_stats();
|
system_stats = await get_system_stats();
|
||||||
|
gps_data = await get_gps();
|
||||||
update_error = undefined;
|
update_error = undefined;
|
||||||
loaded = true;
|
loaded = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -283,6 +290,48 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<SystemStatsTable stats={system_stats!} />
|
<SystemStatsTable stats={system_stats!} />
|
||||||
</div>
|
</div>
|
||||||
|
{#if gps_mode !== 0}
|
||||||
|
<div class="bg-white border border-gray-200 drop-shadow rounded-md p-4 flex flex-col gap-2">
|
||||||
|
<span class="text-lg font-semibold flex flex-row items-center gap-2">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-rayhunter-blue"
|
||||||
|
aria-hidden="true"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M11.906 1.994a8.002 8.002 0 0 1 8.09 8.421 7.996 7.996 0 0 1-1.297 3.957.996.996 0 0 1-.133.204l-.108.129c-.178.243-.37.477-.573.699l-5.112 6.224a1 1 0 0 1-1.545 0L5.982 15.26l-.002-.002a18.146 18.146 0 0 1-.309-.38l-.133-.163a.999.999 0 0 1-.13-.202 7.995 7.995 0 0 1 6.498-12.518ZM15 9.997a3 3 0 1 1-5.999 0 3 3 0 0 1 5.999 0Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
GPS Status
|
||||||
|
</span>
|
||||||
|
{#if gps_data}
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
<tr class="border-b border-gray-100">
|
||||||
|
<td class="py-1 pr-4 text-gray-500 font-medium">Latitude</td>
|
||||||
|
<td class="py-1 font-mono">{gps_data.latitude.toFixed(6)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b border-gray-100">
|
||||||
|
<td class="py-1 pr-4 text-gray-500 font-medium">Longitude</td>
|
||||||
|
<td class="py-1 font-mono">{gps_data.longitude.toFixed(6)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-1 pr-4 text-gray-500 font-medium">GPS Timestamp</td>
|
||||||
|
<td class="py-1 font-mono">{gps_data.timestamp}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{:else}
|
||||||
|
<span class="text-gray-400 text-sm">Awaiting GPS data...</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="flex flex-row gap-2">
|
<div class="flex flex-row gap-2">
|
||||||
<div class="text-xl flex-1">History</div>
|
<div class="text-xl flex-1">History</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user