Fixed control mode tracks art
This commit is contained in:
Generated
+5
-5
@@ -1575,9 +1575,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.16.0"
|
version = "1.17.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
@@ -1718,7 +1718,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "federation-net"
|
name = "federation-net"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://gt.hexor.cy/ab/frid.git#e5353fa9b93d78be6cd811b8430d7dd5e725e6e5"
|
source = "git+https://gt.hexor.cy/ab/frid.git#085a4752da25a8d3fe7eac673af081a4a73c08bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"blake3",
|
"blake3",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
@@ -1845,7 +1845,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -3622,7 +3622,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "music-dht"
|
name = "music-dht"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://gt.hexor.cy/ab/frid.git#e5353fa9b93d78be6cd811b8430d7dd5e725e6e5"
|
source = "git+https://gt.hexor.cy/ab/frid.git#085a4752da25a8d3fe7eac673af081a4a73c08bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"blake3",
|
"blake3",
|
||||||
|
|||||||
+66
-12
@@ -18,6 +18,8 @@ use tokio::io::{AsyncRead, AsyncReadExt};
|
|||||||
|
|
||||||
use crate::player::PlayerDeviceHub;
|
use crate::player::PlayerDeviceHub;
|
||||||
|
|
||||||
|
use super::{TransportStats, record_stream_transport};
|
||||||
|
|
||||||
pub const SYNC_ALPN: &[u8] = b"furumi/sync/1";
|
pub const SYNC_ALPN: &[u8] = b"furumi/sync/1";
|
||||||
|
|
||||||
const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
@@ -457,6 +459,7 @@ pub async fn connect_invite(
|
|||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
service: Arc<MusicDhtService>,
|
service: Arc<MusicDhtService>,
|
||||||
hub: Arc<PlayerDeviceHub>,
|
hub: Arc<PlayerDeviceHub>,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
user_name: &str,
|
user_name: &str,
|
||||||
invite_link: &str,
|
invite_link: &str,
|
||||||
@@ -471,6 +474,7 @@ pub async fn connect_invite(
|
|||||||
pool,
|
pool,
|
||||||
Arc::clone(&service),
|
Arc::clone(&service),
|
||||||
Arc::clone(&hub),
|
Arc::clone(&hub),
|
||||||
|
Arc::clone(&transport_stats),
|
||||||
user_id,
|
user_id,
|
||||||
user_name,
|
user_name,
|
||||||
&invite,
|
&invite,
|
||||||
@@ -884,14 +888,16 @@ pub async fn serve_peers(
|
|||||||
pool: sqlx::PgPool,
|
pool: sqlx::PgPool,
|
||||||
service: Arc<MusicDhtService>,
|
service: Arc<MusicDhtService>,
|
||||||
hub: Arc<PlayerDeviceHub>,
|
hub: Arc<PlayerDeviceHub>,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
) {
|
) {
|
||||||
while let Some(stream) = acceptor.accept().await {
|
while let Some(stream) = acceptor.accept().await {
|
||||||
let pool = pool.clone();
|
let pool = pool.clone();
|
||||||
let service = Arc::clone(&service);
|
let service = Arc::clone(&service);
|
||||||
let hub = Arc::clone(&hub);
|
let hub = Arc::clone(&hub);
|
||||||
|
let transport_stats = Arc::clone(&transport_stats);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let peer = stream.peer_id;
|
let peer = stream.peer_id;
|
||||||
if let Err(err) = serve_one(stream, &pool, service, hub).await {
|
if let Err(err) = serve_one(stream, &pool, service, hub, transport_stats).await {
|
||||||
tracing::warn!(peer = %peer, "web fed device sync stream failed: {err:#}");
|
tracing::warn!(peer = %peer, "web fed device sync stream failed: {err:#}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -902,11 +908,19 @@ pub async fn sync_loop(
|
|||||||
pool: sqlx::PgPool,
|
pool: sqlx::PgPool,
|
||||||
service: Arc<MusicDhtService>,
|
service: Arc<MusicDhtService>,
|
||||||
hub: Arc<PlayerDeviceHub>,
|
hub: Arc<PlayerDeviceHub>,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
) {
|
) {
|
||||||
let mut interval = tokio::time::interval(DEVICE_SYNC_INTERVAL);
|
let mut interval = tokio::time::interval(DEVICE_SYNC_INTERVAL);
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
if let Err(err) = sync_once_all(&pool, Arc::clone(&service), Arc::clone(&hub)).await {
|
if let Err(err) = sync_once_all(
|
||||||
|
&pool,
|
||||||
|
Arc::clone(&service),
|
||||||
|
Arc::clone(&hub),
|
||||||
|
Arc::clone(&transport_stats),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
tracing::debug!("web fed device sync tick failed: {err:#}");
|
tracing::debug!("web fed device sync tick failed: {err:#}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -916,6 +930,7 @@ pub async fn sync_once_all(
|
|||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
service: Arc<MusicDhtService>,
|
service: Arc<MusicDhtService>,
|
||||||
hub: Arc<PlayerDeviceHub>,
|
hub: Arc<PlayerDeviceHub>,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT DISTINCT user_id FROM furumusic__fed_device
|
"SELECT DISTINCT user_id FROM furumusic__fed_device
|
||||||
@@ -925,7 +940,15 @@ pub async fn sync_once_all(
|
|||||||
.await?;
|
.await?;
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let user_id: i64 = row.get("user_id");
|
let user_id: i64 = row.get("user_id");
|
||||||
if let Err(err) = sync_once(pool, Arc::clone(&service), Arc::clone(&hub), user_id).await {
|
if let Err(err) = sync_once(
|
||||||
|
pool,
|
||||||
|
Arc::clone(&service),
|
||||||
|
Arc::clone(&hub),
|
||||||
|
Arc::clone(&transport_stats),
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
set_last_error(pool, user_id, Some(&format!("{err:#}"))).await?;
|
set_last_error(pool, user_id, Some(&format!("{err:#}"))).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -936,6 +959,7 @@ pub async fn sync_once(
|
|||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
service: Arc<MusicDhtService>,
|
service: Arc<MusicDhtService>,
|
||||||
hub: Arc<PlayerDeviceHub>,
|
hub: Arc<PlayerDeviceHub>,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let devices = active_remote_devices(pool, user_id).await?;
|
let devices = active_remote_devices(pool, user_id).await?;
|
||||||
@@ -947,6 +971,7 @@ pub async fn sync_once(
|
|||||||
pool,
|
pool,
|
||||||
Arc::clone(&service),
|
Arc::clone(&service),
|
||||||
Arc::clone(&hub),
|
Arc::clone(&hub),
|
||||||
|
Arc::clone(&transport_stats),
|
||||||
user_id,
|
user_id,
|
||||||
&device,
|
&device,
|
||||||
)
|
)
|
||||||
@@ -968,6 +993,7 @@ async fn try_connect_invite(
|
|||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
service: Arc<MusicDhtService>,
|
service: Arc<MusicDhtService>,
|
||||||
hub: Arc<PlayerDeviceHub>,
|
hub: Arc<PlayerDeviceHub>,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
user_name: &str,
|
user_name: &str,
|
||||||
invite: &InviteWire,
|
invite: &InviteWire,
|
||||||
@@ -984,6 +1010,7 @@ async fn try_connect_invite(
|
|||||||
let snapshot = snapshot(pool, user_id).await?;
|
let snapshot = snapshot(pool, user_id).await?;
|
||||||
let playback = local_playback_snapshot(pool, Arc::clone(&hub), user_id, &identity).await;
|
let playback = local_playback_snapshot(pool, Arc::clone(&hub), user_id, &identity).await;
|
||||||
let mut stream = service.open_stream(peer, SYNC_ALPN).await?;
|
let mut stream = service.open_stream(peer, SYNC_ALPN).await?;
|
||||||
|
record_stream_transport(&transport_stats, "device-sync", "outbound", "open", &stream);
|
||||||
write_msg(
|
write_msg(
|
||||||
&mut stream,
|
&mut stream,
|
||||||
&WireMessage::PairRequest {
|
&WireMessage::PairRequest {
|
||||||
@@ -1001,10 +1028,11 @@ async fn try_connect_invite(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
finish_send(&mut stream).await?;
|
finish_send(&mut stream).await?;
|
||||||
match read_msg(&mut stream)
|
let response = read_msg(&mut stream)
|
||||||
.await
|
.await
|
||||||
.context("pairing response was not received")?
|
.context("pairing response was not received")?;
|
||||||
{
|
record_stream_transport(&transport_stats, "device-sync", "outbound", "done", &stream);
|
||||||
|
match response {
|
||||||
WireMessage::PairResponse {
|
WireMessage::PairResponse {
|
||||||
accepted: true,
|
accepted: true,
|
||||||
group_id: Some(group_id),
|
group_id: Some(group_id),
|
||||||
@@ -1068,7 +1096,9 @@ async fn serve_one(
|
|||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
service: Arc<MusicDhtService>,
|
service: Arc<MusicDhtService>,
|
||||||
hub: Arc<PlayerDeviceHub>,
|
hub: Arc<PlayerDeviceHub>,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
record_stream_transport(&transport_stats, "device-sync", "inbound", "open", &stream);
|
||||||
match read_msg(&mut stream).await? {
|
match read_msg(&mut stream).await? {
|
||||||
WireMessage::PairRequest {
|
WireMessage::PairRequest {
|
||||||
invite_id,
|
invite_id,
|
||||||
@@ -1087,6 +1117,7 @@ async fn serve_one(
|
|||||||
pool,
|
pool,
|
||||||
service,
|
service,
|
||||||
hub,
|
hub,
|
||||||
|
transport_stats,
|
||||||
invite_id,
|
invite_id,
|
||||||
secret,
|
secret,
|
||||||
profile,
|
profile,
|
||||||
@@ -1110,7 +1141,17 @@ async fn serve_one(
|
|||||||
playback,
|
playback,
|
||||||
} => {
|
} => {
|
||||||
handle_hello(
|
handle_hello(
|
||||||
stream, pool, service, hub, group_id, profile, devices, vector, ops, snapshot,
|
stream,
|
||||||
|
pool,
|
||||||
|
service,
|
||||||
|
hub,
|
||||||
|
transport_stats,
|
||||||
|
group_id,
|
||||||
|
profile,
|
||||||
|
devices,
|
||||||
|
vector,
|
||||||
|
ops,
|
||||||
|
snapshot,
|
||||||
playback,
|
playback,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1125,6 +1166,7 @@ async fn handle_pair_request(
|
|||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
service: Arc<MusicDhtService>,
|
service: Arc<MusicDhtService>,
|
||||||
hub: Arc<PlayerDeviceHub>,
|
hub: Arc<PlayerDeviceHub>,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
invite_id: String,
|
invite_id: String,
|
||||||
secret: String,
|
secret: String,
|
||||||
mut profile: DeviceProfileWire,
|
mut profile: DeviceProfileWire,
|
||||||
@@ -1215,6 +1257,7 @@ async fn handle_pair_request(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
finish_response(&mut stream).await?;
|
finish_response(&mut stream).await?;
|
||||||
|
record_stream_transport(&transport_stats, "device-sync", "inbound", "done", &stream);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
Some("accepted") => {}
|
Some("accepted") => {}
|
||||||
@@ -1265,6 +1308,7 @@ async fn handle_pair_request(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
finish_response(&mut stream).await?;
|
finish_response(&mut stream).await?;
|
||||||
|
record_stream_transport(&transport_stats, "device-sync", "inbound", "done", &stream);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1274,6 +1318,7 @@ async fn handle_hello(
|
|||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
service: Arc<MusicDhtService>,
|
service: Arc<MusicDhtService>,
|
||||||
hub: Arc<PlayerDeviceHub>,
|
hub: Arc<PlayerDeviceHub>,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
group_id: String,
|
group_id: String,
|
||||||
mut profile: DeviceProfileWire,
|
mut profile: DeviceProfileWire,
|
||||||
devices: Vec<DeviceProfileWire>,
|
devices: Vec<DeviceProfileWire>,
|
||||||
@@ -1334,6 +1379,7 @@ async fn handle_hello(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
finish_response(&mut stream).await?;
|
finish_response(&mut stream).await?;
|
||||||
|
record_stream_transport(&transport_stats, "device-sync", "inbound", "done", &stream);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1341,6 +1387,7 @@ async fn sync_device(
|
|||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
service: Arc<MusicDhtService>,
|
service: Arc<MusicDhtService>,
|
||||||
hub: Arc<PlayerDeviceHub>,
|
hub: Arc<PlayerDeviceHub>,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
device: &StoredDevice,
|
device: &StoredDevice,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
@@ -1355,6 +1402,7 @@ async fn sync_device(
|
|||||||
let snapshot = snapshot(pool, user_id).await?;
|
let snapshot = snapshot(pool, user_id).await?;
|
||||||
let playback = local_playback_snapshot(pool, Arc::clone(&hub), user_id, &identity).await;
|
let playback = local_playback_snapshot(pool, Arc::clone(&hub), user_id, &identity).await;
|
||||||
let mut stream = service.open_stream(peer, SYNC_ALPN).await?;
|
let mut stream = service.open_stream(peer, SYNC_ALPN).await?;
|
||||||
|
record_stream_transport(&transport_stats, "device-sync", "outbound", "open", &stream);
|
||||||
write_msg(
|
write_msg(
|
||||||
&mut stream,
|
&mut stream,
|
||||||
&WireMessage::Hello {
|
&WireMessage::Hello {
|
||||||
@@ -1369,10 +1417,11 @@ async fn sync_device(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
finish_send(&mut stream).await?;
|
finish_send(&mut stream).await?;
|
||||||
match read_msg(&mut stream)
|
let response = read_msg(&mut stream)
|
||||||
.await
|
.await
|
||||||
.context("device sync response was not received")?
|
.context("device sync response was not received")?;
|
||||||
{
|
record_stream_transport(&transport_stats, "device-sync", "outbound", "done", &stream);
|
||||||
|
match response {
|
||||||
WireMessage::SyncResponse {
|
WireMessage::SyncResponse {
|
||||||
accepted: true,
|
accepted: true,
|
||||||
devices,
|
devices,
|
||||||
@@ -3034,6 +3083,10 @@ fn json_f64(value: f64) -> serde_json::Value {
|
|||||||
.unwrap_or_else(|| serde_json::json!(0.0))
|
.unwrap_or_else(|| serde_json::json!(0.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cover_variant_url(file_id: Option<i64>, variant: &str) -> Option<String> {
|
||||||
|
file_id.map(|id| format!("/api/player/cover/{id}/{variant}"))
|
||||||
|
}
|
||||||
|
|
||||||
async fn web_playback_payload(
|
async fn web_playback_payload(
|
||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
state: &PlaybackStateWire,
|
state: &PlaybackStateWire,
|
||||||
@@ -3265,7 +3318,8 @@ async fn web_track_json_by_id(
|
|||||||
"SELECT t.id, t.title::text AS title, t.track_number, t.disc_number,
|
"SELECT t.id, t.title::text AS title, t.track_number, t.disc_number,
|
||||||
t.duration_seconds, r.id AS release_id, r.title::text AS release_title,
|
t.duration_seconds, r.id AS release_id, r.title::text AS release_title,
|
||||||
r.year AS release_year, mf.audio_format, mf.audio_bitrate,
|
r.year AS release_year, mf.audio_format, mf.audio_bitrate,
|
||||||
mf.audio_sample_rate, mf.audio_bit_depth, mf.file_size_bytes
|
mf.audio_sample_rate, mf.audio_bit_depth, mf.file_size_bytes,
|
||||||
|
COALESCE(t.cover_file_id, r.cover_file_id) AS cover_file_id
|
||||||
FROM furumusic__track t
|
FROM furumusic__track t
|
||||||
JOIN furumusic__release r ON r.id = t.release_id
|
JOIN furumusic__release r ON r.id = t.release_id
|
||||||
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
||||||
@@ -3290,7 +3344,7 @@ async fn web_track_json_by_id(
|
|||||||
"release_id": row.get::<i64, _>("release_id"),
|
"release_id": row.get::<i64, _>("release_id"),
|
||||||
"release_title": row.get::<String, _>("release_title"),
|
"release_title": row.get::<String, _>("release_title"),
|
||||||
"release_year": row.get::<Option<i32>, _>("release_year"),
|
"release_year": row.get::<Option<i32>, _>("release_year"),
|
||||||
"cover_url": serde_json::Value::Null,
|
"cover_url": cover_variant_url(row.get::<Option<i64>, _>("cover_file_id"), "medium"),
|
||||||
"stream_url": format!("/api/player/stream/{track_id}"),
|
"stream_url": format!("/api/player/stream/{track_id}"),
|
||||||
"uploader_name": "Fed",
|
"uploader_name": "Fed",
|
||||||
"audio_format": row.get::<Option<String>, _>("audio_format"),
|
"audio_format": row.get::<Option<String>, _>("audio_format"),
|
||||||
|
|||||||
+170
-5
@@ -16,15 +16,15 @@ pub mod devices;
|
|||||||
mod serve;
|
mod serve;
|
||||||
mod storage;
|
mod storage;
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, OnceLock};
|
use std::sync::{Arc, OnceLock};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use music_dht::{
|
use music_dht::{
|
||||||
ItemKind, ItemSpec, MusicDhtConfig, MusicDhtService, NetworkId, PeerTicket, PublishStats,
|
ByteStream, ByteStreamConnectionStats, ItemKind, ItemSpec, MusicDhtConfig, MusicDhtService,
|
||||||
RendezvousConfig, SyncStats,
|
NetworkId, PeerTicket, PublishStats, RendezvousConfig, SyncStats,
|
||||||
};
|
};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
@@ -37,6 +37,7 @@ pub use serve::{AUDIO_ALPN, CATALOG_ALPN};
|
|||||||
|
|
||||||
/// How often the published library is re-synchronized with the database.
|
/// How often the published library is re-synchronized with the database.
|
||||||
const SYNC_INTERVAL: Duration = Duration::from_secs(60);
|
const SYNC_INTERVAL: Duration = Duration::from_secs(60);
|
||||||
|
const TRANSPORT_SAMPLE_LIMIT: usize = 16;
|
||||||
|
|
||||||
struct Running {
|
struct Running {
|
||||||
service: Arc<MusicDhtService>,
|
service: Arc<MusicDhtService>,
|
||||||
@@ -50,6 +51,157 @@ struct ContentHashJob {
|
|||||||
file_path: String,
|
file_path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct TransportSample {
|
||||||
|
at: String,
|
||||||
|
protocol: &'static str,
|
||||||
|
direction: &'static str,
|
||||||
|
phase: &'static str,
|
||||||
|
peer_id: String,
|
||||||
|
selected_path: String,
|
||||||
|
open_paths: usize,
|
||||||
|
direct_paths: usize,
|
||||||
|
relay_paths: usize,
|
||||||
|
custom_paths: usize,
|
||||||
|
selected_rtt_ms: Option<u64>,
|
||||||
|
selected_tx_bytes: u64,
|
||||||
|
selected_rx_bytes: u64,
|
||||||
|
total_tx_bytes: u64,
|
||||||
|
total_rx_bytes: u64,
|
||||||
|
lost_packets: u64,
|
||||||
|
lost_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TransportSample {
|
||||||
|
fn from_stats(
|
||||||
|
protocol: &'static str,
|
||||||
|
direction: &'static str,
|
||||||
|
phase: &'static str,
|
||||||
|
stats: ByteStreamConnectionStats,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
at: now_iso(),
|
||||||
|
protocol,
|
||||||
|
direction,
|
||||||
|
phase,
|
||||||
|
peer_id: stats.peer_id.to_string(),
|
||||||
|
selected_path: stats.selected_path.as_str().to_string(),
|
||||||
|
open_paths: stats.open_paths,
|
||||||
|
direct_paths: stats.direct_paths,
|
||||||
|
relay_paths: stats.relay_paths,
|
||||||
|
custom_paths: stats.custom_paths,
|
||||||
|
selected_rtt_ms: stats
|
||||||
|
.selected_rtt
|
||||||
|
.map(|duration| duration.as_millis() as u64),
|
||||||
|
selected_tx_bytes: stats.selected_tx_bytes,
|
||||||
|
selected_rx_bytes: stats.selected_rx_bytes,
|
||||||
|
total_tx_bytes: stats.total_tx_bytes,
|
||||||
|
total_rx_bytes: stats.total_rx_bytes,
|
||||||
|
lost_packets: stats.lost_packets,
|
||||||
|
lost_bytes: stats.lost_bytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct TransportStatsState {
|
||||||
|
total_samples: u64,
|
||||||
|
direct_samples: u64,
|
||||||
|
relay_samples: u64,
|
||||||
|
custom_samples: u64,
|
||||||
|
unknown_samples: u64,
|
||||||
|
audio_samples: u64,
|
||||||
|
catalog_samples: u64,
|
||||||
|
sync_samples: u64,
|
||||||
|
last: VecDeque<TransportSample>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct TransportStats {
|
||||||
|
inner: std::sync::Mutex<TransportStatsState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TransportStats {
|
||||||
|
fn reset(&self) {
|
||||||
|
*lock(&self.inner) = TransportStatsState::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(
|
||||||
|
&self,
|
||||||
|
protocol: &'static str,
|
||||||
|
direction: &'static str,
|
||||||
|
phase: &'static str,
|
||||||
|
stats: ByteStreamConnectionStats,
|
||||||
|
) {
|
||||||
|
let sample = TransportSample::from_stats(protocol, direction, phase, stats);
|
||||||
|
let mut state = lock(&self.inner);
|
||||||
|
state.total_samples += 1;
|
||||||
|
match sample.selected_path.as_str() {
|
||||||
|
"direct" => state.direct_samples += 1,
|
||||||
|
"relay" => state.relay_samples += 1,
|
||||||
|
"custom" => state.custom_samples += 1,
|
||||||
|
_ => state.unknown_samples += 1,
|
||||||
|
}
|
||||||
|
match protocol {
|
||||||
|
"audio" => state.audio_samples += 1,
|
||||||
|
"catalog" => state.catalog_samples += 1,
|
||||||
|
"device-sync" => state.sync_samples += 1,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
state.last.push_front(sample);
|
||||||
|
while state.last.len() > TRANSPORT_SAMPLE_LIMIT {
|
||||||
|
state.last.pop_back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot(&self) -> Value {
|
||||||
|
let state = lock(&self.inner);
|
||||||
|
let latest = state.last.front();
|
||||||
|
json!({
|
||||||
|
"total_samples": state.total_samples,
|
||||||
|
"direct_samples": state.direct_samples,
|
||||||
|
"relay_samples": state.relay_samples,
|
||||||
|
"custom_samples": state.custom_samples,
|
||||||
|
"unknown_samples": state.unknown_samples,
|
||||||
|
"audio_samples": state.audio_samples,
|
||||||
|
"catalog_samples": state.catalog_samples,
|
||||||
|
"sync_samples": state.sync_samples,
|
||||||
|
"last_path": latest.map(|sample| sample.selected_path.clone()),
|
||||||
|
"last_rtt_ms": latest.and_then(|sample| sample.selected_rtt_ms),
|
||||||
|
"last_peer": latest.map(|sample| sample.peer_id.clone()),
|
||||||
|
"last": state.last.iter().map(|sample| json!({
|
||||||
|
"at": sample.at,
|
||||||
|
"protocol": sample.protocol,
|
||||||
|
"direction": sample.direction,
|
||||||
|
"phase": sample.phase,
|
||||||
|
"peer_id": sample.peer_id,
|
||||||
|
"selected_path": sample.selected_path,
|
||||||
|
"open_paths": sample.open_paths,
|
||||||
|
"direct_paths": sample.direct_paths,
|
||||||
|
"relay_paths": sample.relay_paths,
|
||||||
|
"custom_paths": sample.custom_paths,
|
||||||
|
"selected_rtt_ms": sample.selected_rtt_ms,
|
||||||
|
"selected_tx_bytes": sample.selected_tx_bytes,
|
||||||
|
"selected_rx_bytes": sample.selected_rx_bytes,
|
||||||
|
"total_tx_bytes": sample.total_tx_bytes,
|
||||||
|
"total_rx_bytes": sample.total_rx_bytes,
|
||||||
|
"lost_packets": sample.lost_packets,
|
||||||
|
"lost_bytes": sample.lost_bytes,
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_stream_transport(
|
||||||
|
stats: &Arc<TransportStats>,
|
||||||
|
protocol: &'static str,
|
||||||
|
direction: &'static str,
|
||||||
|
phase: &'static str,
|
||||||
|
stream: &ByteStream,
|
||||||
|
) {
|
||||||
|
stats.record(protocol, direction, phase, stream.connection_stats());
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Federation {
|
pub struct Federation {
|
||||||
/// Transport data directory; server-side DHT state and identity live in PostgreSQL.
|
/// Transport data directory; server-side DHT state and identity live in PostgreSQL.
|
||||||
data_dir: PathBuf,
|
data_dir: PathBuf,
|
||||||
@@ -61,6 +213,7 @@ pub struct Federation {
|
|||||||
running: tokio::sync::Mutex<Option<Running>>,
|
running: tokio::sync::Mutex<Option<Running>>,
|
||||||
last_sync: std::sync::Mutex<Option<String>>,
|
last_sync: std::sync::Mutex<Option<String>>,
|
||||||
last_error: std::sync::Mutex<Option<String>>,
|
last_error: std::sync::Mutex<Option<String>>,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn now_iso() -> String {
|
fn now_iso() -> String {
|
||||||
@@ -87,6 +240,7 @@ pub fn handle() -> Arc<Federation> {
|
|||||||
running: tokio::sync::Mutex::new(None),
|
running: tokio::sync::Mutex::new(None),
|
||||||
last_sync: std::sync::Mutex::new(None),
|
last_sync: std::sync::Mutex::new(None),
|
||||||
last_error: std::sync::Mutex::new(None),
|
last_error: std::sync::Mutex::new(None),
|
||||||
|
transport_stats: Arc::new(TransportStats::default()),
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -189,6 +343,7 @@ impl Federation {
|
|||||||
|
|
||||||
let dht_storage = Arc::new(PostgresFederationStorage::new(pool.clone()).await?);
|
let dht_storage = Arc::new(PostgresFederationStorage::new(pool.clone()).await?);
|
||||||
let secret_key = dht_storage.load_or_create_secret_key().await?;
|
let secret_key = dht_storage.load_or_create_secret_key().await?;
|
||||||
|
self.transport_stats.reset();
|
||||||
|
|
||||||
let config = MusicDhtConfig::builder()
|
let config = MusicDhtConfig::builder()
|
||||||
.data_dir(&self.data_dir)
|
.data_dir(&self.data_dir)
|
||||||
@@ -236,6 +391,7 @@ impl Federation {
|
|||||||
pool.clone(),
|
pool.clone(),
|
||||||
storage_dir.clone(),
|
storage_dir.clone(),
|
||||||
service.endpoint_id(),
|
service.endpoint_id(),
|
||||||
|
Arc::clone(&self.transport_stats),
|
||||||
));
|
));
|
||||||
let catalog_acceptor = service
|
let catalog_acceptor = service
|
||||||
.stream_acceptor(CATALOG_ALPN)
|
.stream_acceptor(CATALOG_ALPN)
|
||||||
@@ -245,6 +401,7 @@ impl Federation {
|
|||||||
pool.clone(),
|
pool.clone(),
|
||||||
storage_dir,
|
storage_dir,
|
||||||
service.endpoint_id(),
|
service.endpoint_id(),
|
||||||
|
Arc::clone(&self.transport_stats),
|
||||||
));
|
));
|
||||||
let device_acceptor = service
|
let device_acceptor = service
|
||||||
.stream_acceptor(devices::SYNC_ALPN)
|
.stream_acceptor(devices::SYNC_ALPN)
|
||||||
@@ -255,9 +412,14 @@ impl Federation {
|
|||||||
pool.clone(),
|
pool.clone(),
|
||||||
Arc::clone(&service),
|
Arc::clone(&service),
|
||||||
Arc::clone(&device_hub),
|
Arc::clone(&device_hub),
|
||||||
|
Arc::clone(&self.transport_stats),
|
||||||
|
));
|
||||||
|
let device_sync_task = tokio::spawn(devices::sync_loop(
|
||||||
|
pool,
|
||||||
|
Arc::clone(&service),
|
||||||
|
device_hub,
|
||||||
|
Arc::clone(&self.transport_stats),
|
||||||
));
|
));
|
||||||
let device_sync_task =
|
|
||||||
tokio::spawn(devices::sync_loop(pool, Arc::clone(&service), device_hub));
|
|
||||||
|
|
||||||
*guard = Some(Running {
|
*guard = Some(Running {
|
||||||
service,
|
service,
|
||||||
@@ -596,6 +758,7 @@ impl Federation {
|
|||||||
"connected_peers": peers,
|
"connected_peers": peers,
|
||||||
"known_contacts": service.known_peers().len(),
|
"known_contacts": service.known_peers().len(),
|
||||||
"published_items": published,
|
"published_items": published,
|
||||||
|
"transport": self.transport_stats.snapshot(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
None => json!({ "running": false }),
|
None => json!({ "running": false }),
|
||||||
@@ -668,6 +831,7 @@ impl Federation {
|
|||||||
&pool,
|
&pool,
|
||||||
service,
|
service,
|
||||||
crate::player::PlayerDeviceHub::shared(),
|
crate::player::PlayerDeviceHub::shared(),
|
||||||
|
Arc::clone(&self.transport_stats),
|
||||||
user_id,
|
user_id,
|
||||||
user_name,
|
user_name,
|
||||||
invite,
|
invite,
|
||||||
@@ -698,6 +862,7 @@ impl Federation {
|
|||||||
&pool,
|
&pool,
|
||||||
service,
|
service,
|
||||||
crate::player::PlayerDeviceHub::shared(),
|
crate::player::PlayerDeviceHub::shared(),
|
||||||
|
Arc::clone(&self.transport_stats),
|
||||||
user_id,
|
user_id,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
+18
-2
@@ -3,6 +3,7 @@
|
|||||||
//! with the furumi TUI client and any other furumi peer.
|
//! with the furumi TUI client and any other furumi peer.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use music_dht::{ByteStream, EndpointId, ItemId, ItemKind, StreamAcceptor};
|
use music_dht::{ByteStream, EndpointId, ItemId, ItemKind, StreamAcceptor};
|
||||||
@@ -11,6 +12,8 @@ use sqlx::PgPool;
|
|||||||
use sqlx::Row as _;
|
use sqlx::Row as _;
|
||||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
|
use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
use super::{TransportStats, record_stream_transport};
|
||||||
|
|
||||||
/// ALPN of the peer-to-peer audio streaming protocol.
|
/// ALPN of the peer-to-peer audio streaming protocol.
|
||||||
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
|
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
|
||||||
/// ALPN of the per-artist catalog protocol.
|
/// ALPN of the per-artist catalog protocol.
|
||||||
@@ -344,13 +347,16 @@ pub async fn serve_audio(
|
|||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
storage_dir: String,
|
storage_dir: String,
|
||||||
own: EndpointId,
|
own: EndpointId,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
) {
|
) {
|
||||||
while let Some(stream) = acceptor.accept().await {
|
while let Some(stream) = acceptor.accept().await {
|
||||||
let pool = pool.clone();
|
let pool = pool.clone();
|
||||||
let storage_dir = storage_dir.clone();
|
let storage_dir = storage_dir.clone();
|
||||||
|
let transport_stats = Arc::clone(&transport_stats);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let peer = stream.peer_id;
|
let peer = stream.peer_id;
|
||||||
if let Err(err) = serve_audio_one(stream, pool, storage_dir, own).await {
|
if let Err(err) = serve_audio_one(stream, pool, storage_dir, own, transport_stats).await
|
||||||
|
{
|
||||||
tracing::warn!(peer = %peer, "federation audio stream failed: {err:#}");
|
tracing::warn!(peer = %peer, "federation audio stream failed: {err:#}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -362,7 +368,9 @@ async fn serve_audio_one(
|
|||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
storage_dir: String,
|
storage_dir: String,
|
||||||
own: EndpointId,
|
own: EndpointId,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
record_stream_transport(&transport_stats, "audio", "inbound", "open", &stream);
|
||||||
let request: AudioRequest = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
|
let request: AudioRequest = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
peer = %stream.peer_id,
|
peer = %stream.peer_id,
|
||||||
@@ -465,6 +473,7 @@ async fn serve_audio_one(
|
|||||||
// Wait until the peer read everything before dropping the stream,
|
// Wait until the peer read everything before dropping the stream,
|
||||||
// otherwise the tail of the file is lost.
|
// otherwise the tail of the file is lost.
|
||||||
let _ = stream.send.stopped().await;
|
let _ = stream.send.stopped().await;
|
||||||
|
record_stream_transport(&transport_stats, "audio", "inbound", "done", &stream);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,13 +502,17 @@ pub async fn serve_catalog(
|
|||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
storage_dir: String,
|
storage_dir: String,
|
||||||
own: EndpointId,
|
own: EndpointId,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
) {
|
) {
|
||||||
while let Some(stream) = acceptor.accept().await {
|
while let Some(stream) = acceptor.accept().await {
|
||||||
let pool = pool.clone();
|
let pool = pool.clone();
|
||||||
let storage_dir = storage_dir.clone();
|
let storage_dir = storage_dir.clone();
|
||||||
|
let transport_stats = Arc::clone(&transport_stats);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let peer = stream.peer_id;
|
let peer = stream.peer_id;
|
||||||
if let Err(err) = serve_catalog_one(stream, pool, storage_dir, own).await {
|
if let Err(err) =
|
||||||
|
serve_catalog_one(stream, pool, storage_dir, own, transport_stats).await
|
||||||
|
{
|
||||||
tracing::warn!(peer = %peer, "federation catalog request failed: {err:#}");
|
tracing::warn!(peer = %peer, "federation catalog request failed: {err:#}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -511,7 +524,9 @@ async fn serve_catalog_one(
|
|||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
storage_dir: String,
|
storage_dir: String,
|
||||||
own: EndpointId,
|
own: EndpointId,
|
||||||
|
transport_stats: Arc<TransportStats>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
record_stream_transport(&transport_stats, "catalog", "inbound", "open", &stream);
|
||||||
let request: CatalogRequest = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
|
let request: CatalogRequest = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
peer = %stream.peer_id,
|
peer = %stream.peer_id,
|
||||||
@@ -579,6 +594,7 @@ async fn serve_catalog_one(
|
|||||||
}
|
}
|
||||||
stream.send.finish()?;
|
stream.send.finish()?;
|
||||||
let _ = stream.send.stopped().await;
|
let _ = stream.send.stopped().await;
|
||||||
|
record_stream_transport(&transport_stats, "catalog", "inbound", "done", &stream);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2303,6 +2303,29 @@ tbody tr:hover {
|
|||||||
<div class="probe-row"><span>Published items</span><strong x-text="(federationStatus.node && federationStatus.node.published_items) ?? '-'"></strong></div>
|
<div class="probe-row"><span>Published items</span><strong x-text="(federationStatus.node && federationStatus.node.published_items) ?? '-'"></strong></div>
|
||||||
<div class="probe-row"><span>Last sync</span><strong x-text="federationStatus.last_sync || 'not yet'"></strong></div>
|
<div class="probe-row"><span>Last sync</span><strong x-text="federationStatus.last_sync || 'not yet'"></strong></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="probe-table" x-show="fedTransport().total_samples > 0" style="margin-top:10px">
|
||||||
|
<div class="probe-row">
|
||||||
|
<span>Transport path</span>
|
||||||
|
<strong>
|
||||||
|
<span class="badge" :class="fedPathBadge(fedTransport().last_path)" x-text="fedTransport().last_path || 'unknown'"></span>
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div class="probe-row"><span>RTT</span><strong x-text="fedRtt(fedTransport().last_rtt_ms)"></strong></div>
|
||||||
|
<div class="probe-row"><span>Path samples</span><strong x-text="`${fedTransport().direct_samples || 0} direct · ${fedTransport().relay_samples || 0} relay · ${fedTransport().custom_samples || 0} custom · ${fedTransport().unknown_samples || 0} unknown`"></strong></div>
|
||||||
|
<div class="probe-row"><span>Protocols</span><strong x-text="`${fedTransport().audio_samples || 0} audio · ${fedTransport().catalog_samples || 0} catalog · ${fedTransport().sync_samples || 0} sync`"></strong></div>
|
||||||
|
<div class="probe-row"><span>Last peer</span><strong x-text="fedShort(fedTransport().last_peer)"></strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="probe-table" x-show="fedTransport().last && fedTransport().last.length" style="margin-top:10px">
|
||||||
|
<template x-for="(sample, index) in fedTransport().last.slice(0, 5)" :key="`${sample.at}-${sample.protocol}-${sample.direction}-${sample.phase}-${sample.peer_id}-${index}`">
|
||||||
|
<div class="probe-row">
|
||||||
|
<span x-text="`${sample.protocol} · ${sample.direction} · ${sample.phase}`"></span>
|
||||||
|
<strong>
|
||||||
|
<span class="badge" :class="fedPathBadge(sample.selected_path)" x-text="sample.selected_path || 'unknown'"></span>
|
||||||
|
<span x-text="` ${fedRtt(sample.selected_rtt_ms)} · tx ${formatBytes(sample.total_tx_bytes || 0)} · rx ${formatBytes(sample.total_rx_bytes || 0)} · lost ${formatBytes(sample.lost_bytes || 0)}`"></span>
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
<p class="probe-intro muted" x-show="federationStatus.last_error" x-text="federationStatus.last_error"></p>
|
<p class="probe-intro muted" x-show="federationStatus.last_error" x-text="federationStatus.last_error"></p>
|
||||||
<div class="toolbar" style="margin-top:14px; flex-wrap:wrap; gap:8px">
|
<div class="toolbar" style="margin-top:14px; flex-wrap:wrap; gap:8px">
|
||||||
<button class="btn" type="button" @click="loadFederation()" :disabled="federationLoading">
|
<button class="btn" type="button" @click="loadFederation()" :disabled="federationLoading">
|
||||||
@@ -3268,6 +3291,21 @@ function adminV2() {
|
|||||||
return id ? `${id.slice(0, 12)}…` : '-';
|
return id ? `${id.slice(0, 12)}…` : '-';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
fedTransport() {
|
||||||
|
return (this.federationStatus.node && this.federationStatus.node.transport) || {};
|
||||||
|
},
|
||||||
|
|
||||||
|
fedPathBadge(path) {
|
||||||
|
if (path === 'direct') return 'ok';
|
||||||
|
if (path === 'relay') return 'pending';
|
||||||
|
if (path === 'custom') return 'running';
|
||||||
|
return 'disabled';
|
||||||
|
},
|
||||||
|
|
||||||
|
fedRtt(ms) {
|
||||||
|
return ms != null ? `${Math.round(Number(ms))} ms` : '-';
|
||||||
|
},
|
||||||
|
|
||||||
async loadSettingsProbe(showErrors = true) {
|
async loadSettingsProbe(showErrors = true) {
|
||||||
this.settingsProbeLoading = true;
|
this.settingsProbeLoading = true;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user