Add "last seen" to server list

main
_ 2020-11-25 02:17:08 +00:00
parent a3e76cf120
commit 7aafbba4d9
6 changed files with 196 additions and 55 deletions

View File

@ -13,6 +13,7 @@ license = "AGPL-3.0"
aho-corasick = "0.7.14"
base64 = "0.12.3"
blake3 = "0.3.7"
chrono = "0.4.19"
ctrlc = { version = "3.1.7", features = [ "termination" ] }
dashmap = "3.11.10"
futures = "0.3.7"

View File

@ -6,12 +6,25 @@
body {
font-family: sans-serif;
}
.entry {
display: inline-block;
padding: 20px;
min-width: 50%;
td {
padding: 0px;
}
.entry_list div:nth-child(odd) {
td > * {
padding: 20px;
display: block;
}
.entry {
}
.grey {
color: #888;
text-align: right;
}
.entry_list {
width: 100%;
border-collapse: collapse;
}
tbody tr:nth-child(odd) {
background-color: #ddd;
}
</style>
@ -21,19 +34,28 @@
<h1>Server list</h1>
<div class="entry_list">
{{#if servers}}
<table class="entry_list">
<thead>
<tr>
<th>Name</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
{{#each servers}}
<div>
<a class="entry" href="{{this.path}}/files/">{{this.name}}</a>
</div>
<tr>
<td><a class="entry" href="{{this.path}}/files/">{{this.name}}</a></td>
<td><span class="grey">{{this.last_seen}}</span></td>
</tr>
{{/each}}
</tbody>
</table>
{{else}}
(No servers are running)
(No servers have reported since this relay started)
{{/if}}
</div>
</body>
</html>

View File

@ -6,19 +6,25 @@
body {
font-family: sans-serif;
}
.entry {
display: inline-block;
td {
padding: 0;
}
td > * {
padding: 10px;
width: 100%;
display: block;
}
.entry {
text-decoration: none;
}
.grey {
color: #888;
text-align: right;
}
.entry_list {
width: 100%;
border-collapse: collapse;
}
.entry_list tr:nth-child(even) {
tbody tr:nth-child(odd) {
background-color: #ddd;
}
</style>

View File

@ -127,12 +127,32 @@ impl From <&ConfigFile> for Config {
}
}
use chrono::{
DateTime,
SecondsFormat,
Utc
};
#[derive (Clone)]
pub struct ServerStatus {
last_seen: DateTime <Utc>,
}
impl Default for ServerStatus {
fn default () -> Self {
Self {
last_seen: Utc::now (),
}
}
}
pub struct RelayState {
config: Config,
handlebars: Arc <Handlebars <'static>>,
// Key: Server ID
request_rendezvous: Mutex <HashMap <String, RequestRendezvous>>,
server_status: Mutex <HashMap <String, ServerStatus>>,
// Key: Request ID
response_rendezvous: RwLock <DashMap <String, ResponseRendezvous>>,
@ -149,6 +169,7 @@ impl From <&ConfigFile> for RelayState {
config: Config::from (config_file),
handlebars: Arc::new (load_templates (&PathBuf::new ()).unwrap ()),
request_rendezvous: Default::default (),
server_status: Default::default (),
response_rendezvous: Default::default (),
shutdown_watch_tx,
shutdown_watch_rx,
@ -205,6 +226,16 @@ async fn handle_http_listen (
return trip_error;
}
// End of early returns
{
let mut server_status = state.server_status.lock ().await;
let mut status = server_status.entry (watcher_code.clone ()).or_insert_with (Default::default);
status.last_seen = Utc::now ();
}
use RequestRendezvous::*;
let (tx, rx) = oneshot::channel ();
@ -454,6 +485,99 @@ async fn handle_http_request (
}
}
#[derive (Debug, PartialEq)]
enum LastSeen {
Negative,
Connected,
Description (String),
}
// Mnemonic is "now - last_seen"
fn pretty_print_last_seen (
now: DateTime <Utc>,
last_seen: DateTime <Utc>
) -> LastSeen
{
use LastSeen::*;
let dur = now.signed_duration_since (last_seen);
if dur < chrono::Duration::zero () {
return Negative;
}
if dur.num_minutes () < 1 {
return Connected;
}
if dur.num_hours () < 1 {
return Description (format! ("{} m ago", dur.num_minutes ()));
}
if dur.num_days () < 1 {
return Description (format! ("{} h ago", dur.num_hours ()));
}
Description (last_seen.to_rfc3339_opts (SecondsFormat::Secs, true))
}
async fn handle_server_list (
state: Arc <RelayState>
) -> Response <Body>
{
use std::borrow::Cow;
#[derive (Serialize)]
struct ServerEntry <'a> {
path: String,
name: String,
last_seen: Cow <'a, str>,
}
#[derive (Serialize)]
struct ServerListPage <'a> {
servers: Vec <ServerEntry <'a>>,
}
let servers = {
let guard = state.server_status.lock ().await;
(*guard).clone ()
};
let now = Utc::now ();
let mut servers: Vec <_> = servers.into_iter ()
.map (|(name, server)| {
let display_name = percent_encoding::percent_decode_str (&name).decode_utf8 ().unwrap_or_else (|_| "Server name isn't UTF-8".into ()).to_string ();
use LastSeen::*;
let last_seen = match pretty_print_last_seen (now, server.last_seen) {
Negative => "Error (negative time)".into (),
Connected => "Connected".into (),
Description (s) => s.into (),
};
ServerEntry {
name: display_name,
path: name,
last_seen: last_seen,
}
})
.collect ();
servers.sort_by (|a, b| a.name.cmp (&b.name));
let page = ServerListPage {
servers,
};
let s = state.handlebars.render ("relay_server_list", &page).unwrap ();
ok_reply (s)
}
#[instrument (level = "trace", skip (req, state))]
async fn handle_all (req: Request <Body>, state: Arc <RelayState>)
-> Result <Response <Body>, Infallible>
@ -487,35 +611,7 @@ async fn handle_all (req: Request <Body>, state: Arc <RelayState>)
}
else if let Some (rest) = prefix_match ("/frontend/servers/", path) {
if rest == "" {
use std::borrow::Cow;
#[derive (Serialize)]
struct ServerEntry <'a> {
path: &'a str,
name: Cow <'a, str>,
}
#[derive (Serialize)]
struct ServerListPage <'a> {
servers: Vec <ServerEntry <'a>>,
}
let mut names = state.list_servers ().await;
names.sort ();
//println! ("Found {} servers", names.len ());
let page = ServerListPage {
servers: names.iter ()
.map (|name| ServerEntry {
name: percent_encoding::percent_decode_str (name).decode_utf8 ().unwrap_or_else (|_| "Server name isn't UTF-8".into ()),
path: &name,
})
.collect (),
};
let s = state.handlebars.render ("relay_server_list", &page).unwrap ();
ok_reply (s)
handle_server_list (state).await
}
else if let Some (idx) = rest.find ('/') {
let listen_code = String::from (&rest [0..idx]);
@ -635,5 +731,29 @@ pub async fn run_relay (
#[cfg (test)]
mod tests {
use super::*;
#[test]
fn test_pretty_print_last_seen () {
use LastSeen::*;
let last_seen = DateTime::parse_from_rfc3339 ("2019-05-29T00:00:00+00:00").unwrap ().with_timezone (&Utc);
for (input, expected) in vec! [
("2019-05-28T23:59:59+00:00", Negative),
("2019-05-29T00:00:00+00:00", Connected),
("2019-05-29T00:00:59+00:00", Connected),
("2019-05-29T00:01:30+00:00", Description ("1 m ago".into ())),
("2019-05-29T00:59:30+00:00", Description ("59 m ago".into ())),
("2019-05-29T01:00:30+00:00", Description ("1 h ago".into ())),
("2019-05-29T10:00:00+00:00", Description ("10 h ago".into ())),
("2019-05-30T00:00:00+00:00", Description ("2019-05-29T00:00:00Z".into ())),
("2019-05-30T10:00:00+00:00", Description ("2019-05-29T00:00:00Z".into ())),
("2019-05-31T00:00:00+00:00", Description ("2019-05-29T00:00:00Z".into ())),
].into_iter () {
let now = DateTime::parse_from_rfc3339 (input).unwrap ().with_timezone (&Utc);
let actual = pretty_print_last_seen (now, last_seen);
assert_eq! (actual, expected);
}
}
}

View File

@ -685,20 +685,13 @@ mod tests {
ffi::OsStr,
path::{
Component,
Path,
PathBuf
Path,
},
};
use maplit::*;
use tokio::runtime::Runtime;
use always_equal::test::AlwaysEqual;
use crate::http_serde::{
StatusCode,
};
#[test]
fn icons () {
let video = "🎞️";

View File

@ -10,7 +10,6 @@
- ETag cache based on mtime
- Server-side hash?
- Log / audit log?
- Add "Last check-in time" to server list
- Prevent directory traversal attacks in file_server.rs
- Error handling