allows scrolling backwards through history

This commit is contained in:
2025-01-05 10:52:09 -05:00
parent 32fcbbd916
commit 2cb1eec404
11 changed files with 214 additions and 87 deletions

View File

@@ -3,6 +3,7 @@ import { computed, inject, provide, ref, toValue, watch } from 'vue';
import { AXIS_DATA, type AxisData, AxisType } from '@/graph/axis';
import { GRAPH_DATA, type GraphData, GraphSide } from '@/graph/graph';
import NumericText from '@/components/NumericText.vue';
import { useNow } from '@/composables/ticker';
const props = defineProps<{
y_limits?: [number, number];
@@ -28,7 +29,9 @@ const raw_max_y = ref(-Infinity);
const axis_update_watch = ref(0);
watch([graph_data.min_x, graph_data.max_x], () => {
const axis_update_ticker = useNow(50);
watch([graph_data.min_x, graph_data.max_x, axis_update_ticker], () => {
axis_update_watch.value++;
min_y.value = raw_min_y.value;
max_y.value = raw_max_y.value;

View File

@@ -11,6 +11,7 @@ import {
import { useNow } from '@/composables/ticker';
import { GRAPH_DATA, type GraphData, GraphSide } from '@/graph/graph';
import TimeText from '@/components/TimeText.vue';
import { getDateString } from '@/datetime';
const props = defineProps<{
duration?: number;
@@ -107,11 +108,47 @@ const border_right = computed(
const border_top = computed(() => 6);
const border_bottom = computed(() => (props.hide_time_labels ? 6 : 24));
const max_x = now;
const max_x = ref(now.value);
const min_x = computed(() => max_x.value - window_duration.value);
const fetch_history = ref(0);
const diff_x = computed(() => max_x.value - min_x.value);
const live = ref(true);
watch([live], ([live_value]) => {
if (live_value) {
fetch_history.value++;
}
});
const valid_max_x_text = ref(true);
watch([now], ([now_value]) => {
if (live.value) {
max_x.value = now_value;
valid_max_x_text.value = true;
}
});
const max_x_text = computed({
// getter
get() {
return getDateString(new Date(max_x.value), props.utc, true);
},
// setter
set(newValue) {
const new_max_x = Date.parse(
newValue.replace('/', '-').replace('/', '-').replace(' ', 'T'),
);
if (!Number.isNaN(new_max_x)) {
fetch_history.value++;
max_x.value = new_max_x;
valid_max_x_text.value = true;
} else {
valid_max_x_text.value = false;
}
},
});
const x_map = (x: number) => {
return (
((width.value - border_left.value - border_right.value) *
@@ -203,7 +240,9 @@ const should_fade = ref(false);
provide<GraphData>(GRAPH_DATA, {
border_top: border_top,
min_x: min_x,
max_x: now,
max_x: max_x,
live: live,
fetch_history: fetch_history,
width: () =>
Math.max(width.value - border_left.value - border_right.value, 0),
height: () =>
@@ -231,7 +270,17 @@ provide<GraphData>(GRAPH_DATA, {
>
<div class="grow"></div>
<div>
<span>Duration Dropdown</span>
<input
type="text"
autocomplete="off"
:disabled="live"
:size="max_x_text.length"
v-model="max_x_text"
/>
</div>
<div>
<input type="checkbox" v-model="live" />
<label>Live</label>
</div>
</div>
<svg

View File

@@ -28,7 +28,6 @@ const props = defineProps<{
data: string;
minimum_separation?: number;
class?: string;
fetch_history?: number;
}>();
const smoothing_distance_x = 5;
@@ -42,13 +41,17 @@ const min_sep = computed(() =>
Math.min(props.minimum_separation || 0, maximum_minimum_separation_live),
);
const { data: telemetry_data } = useTelemetry(() => props.data);
const websocket = inject<ShallowRef<WebsocketHandle>>(WEBSOCKET_SYMBOL)!;
const value = websocket.value.listen_to_telemetry(telemetry_data, min_sep);
const graph_data = inject<GraphData>(GRAPH_DATA)!;
const axis_data = inject<AxisData>(AXIS_DATA)!;
const { data: telemetry_data } = useTelemetry(() => props.data);
const websocket = inject<ShallowRef<WebsocketHandle>>(WEBSOCKET_SYMBOL)!;
const value = websocket.value.listen_to_telemetry(
telemetry_data,
min_sep,
graph_data.live,
);
const min = ref(Infinity);
const max = ref(-Infinity);
@@ -104,8 +107,9 @@ watch([value], ([val]) => {
}
}
});
const recompute_bounds = ref(0);
watch(
[telemetry_data, () => props.fetch_history],
[telemetry_data, () => toValue(graph_data.fetch_history)],
async ([data]) => {
if (data) {
const uuid = data.uuid;
@@ -137,9 +141,10 @@ watch(
);
triggerRef(memo);
debounced_recompute();
recompute_bounds.value++;
} catch (e) {
// TODO: Response?
console.log(e);
console.error(e);
}
}
},
@@ -170,6 +175,10 @@ watch([graph_data.min_x, graph_data.max_x], ([min_x, max_x]) => {
}
}
if (memo_changed) {
recompute_bounds.value++;
}
});
watch([recompute_bounds], () => {
let min_val = Infinity;
let max_val = -Infinity;
for (let i = 1; i < memo.value.data.length; i++) {
@@ -181,7 +190,6 @@ watch([graph_data.min_x, graph_data.max_x], ([min_x, max_x]) => {
debounced_recompute();
max.value = max_val;
min.value = min_val;
}
});
watch(
@@ -354,7 +362,7 @@ function onMouseExit(event: MouseEvent) {
:cx="marker_radius"
:cy="marker_radius"
:r="marker_radius"
:class="`indexed-color color-${index}`"
:class="`indexed-color color-${index} marker`"
/>
</marker>
</defs>
@@ -446,6 +454,10 @@ function onMouseExit(event: MouseEvent) {
<style lang="scss">
@use '@/assets/variables';
.fade rect.fade_other_selected {
opacity: 10%;
}
.fade .fade_other_selected {
opacity: 25%;
}
@@ -463,6 +475,11 @@ function onMouseExit(event: MouseEvent) {
fill: var(--indexed-color);
}
circle.marker {
stroke: variables.$background-color;
stroke-width: 1px;
}
polyline {
stroke-width: 1px;
}

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { getDateString } from '@/datetime';
const props = defineProps<{
x: number;
@@ -9,57 +10,12 @@ const props = defineProps<{
show_millis?: boolean;
}>();
// This function is slow
function getDateString(date: Date) {
const year = props.utc ? date.getUTCFullYear() : date.getFullYear();
const month = (
(props.utc ? date.getMonth() : date.getMonth()) + 1
).toLocaleString('en-US', {
minimumIntegerDigits: 2,
useGrouping: false,
maximumFractionDigits: 0,
});
const day = (props.utc ? date.getUTCDate() : date.getDate()).toLocaleString(
'en-US',
{
minimumIntegerDigits: 2,
useGrouping: false,
maximumFractionDigits: 0,
},
);
const hour = (
props.utc ? date.getUTCHours() : date.getHours()
).toLocaleString('en-US', {
minimumIntegerDigits: 2,
useGrouping: false,
maximumFractionDigits: 0,
});
const minute = (
props.utc ? date.getUTCMinutes() : date.getMinutes()
).toLocaleString('en-US', {
minimumIntegerDigits: 2,
useGrouping: false,
maximumFractionDigits: 0,
});
const second = (
props.utc ? date.getUTCSeconds() : date.getSeconds()
).toLocaleString('en-US', {
minimumIntegerDigits: 2,
useGrouping: false,
maximumFractionDigits: 0,
});
const milliseconds = (
props.utc ? date.getUTCMilliseconds() : date.getMilliseconds()
).toLocaleString('en-US', {
minimumIntegerDigits: 3,
useGrouping: false,
maximumFractionDigits: 0,
});
return `${year}/${month}/${day} ${hour}:${minute}:${second}${props.show_millis ? `.${milliseconds}` : ''}${props.utc ? 'Z' : ''}`;
}
const timetext = computed(() => {
return getDateString(new Date(props.timestamp));
return getDateString(
new Date(props.timestamp),
props.utc,
props.show_millis,
);
});
</script>

View File

@@ -3,6 +3,7 @@ import {
type MaybeRefOrGetter,
onMounted,
onUnmounted,
onWatcherCleanup,
ref,
type Ref,
shallowRef,
@@ -95,6 +96,7 @@ export class WebsocketHandle {
listen_to_telemetry(
telemetry: MaybeRefOrGetter<TelemetryDefinition | null>,
minimum_separation_ms: MaybeRefOrGetter<number> | undefined,
live: MaybeRefOrGetter<boolean>,
) {
const value_result = ref<TelemetryDataItem | null>(null);
@@ -114,10 +116,12 @@ export class WebsocketHandle {
return 0;
});
const is_live = computed(() => toValue(live));
watch(
[uuid, this.connected, minimum_separation],
([uuid_value, connected, min_sep]) => {
if (connected && uuid_value) {
[uuid, this.connected, minimum_separation, is_live],
([uuid_value, connected, min_sep, live_value]) => {
if (connected && uuid_value && live_value) {
this.websocket?.send(
JSON.stringify({
RegisterTlmListener: {
@@ -129,8 +133,26 @@ export class WebsocketHandle {
if (!this.on_telem_value.has(uuid_value)) {
this.on_telem_value.set(uuid_value, []);
}
this.on_telem_value.get(uuid_value)?.push((value) => {
const callback_fn = (value: TelemetryDataItem) => {
value_result.value = value;
};
this.on_telem_value.get(uuid_value)?.push(callback_fn);
onWatcherCleanup(() => {
this.websocket?.send(
JSON.stringify({
UnregisterTlmListener: {
uuid: uuid_value,
},
}),
);
const index = this.on_telem_value
.get(uuid_value)
?.indexOf(callback_fn);
if (index !== undefined && index >= 0) {
this.on_telem_value
.get(uuid_value)
?.splice(index, 1);
}
});
}
},

49
frontend/src/datetime.ts Normal file
View File

@@ -0,0 +1,49 @@
// This function is slow
export function getDateString(date: Date, utc: boolean, millis: boolean) {
const year = utc ? date.getUTCFullYear() : date.getFullYear();
const month = (
(utc ? date.getMonth() : date.getMonth()) + 1
).toLocaleString('en-US', {
minimumIntegerDigits: 2,
useGrouping: false,
maximumFractionDigits: 0,
});
const day = (utc ? date.getUTCDate() : date.getDate()).toLocaleString(
'en-US',
{
minimumIntegerDigits: 2,
useGrouping: false,
maximumFractionDigits: 0,
},
);
const hour = (utc ? date.getUTCHours() : date.getHours()).toLocaleString(
'en-US',
{
minimumIntegerDigits: 2,
useGrouping: false,
maximumFractionDigits: 0,
},
);
const minute = (
utc ? date.getUTCMinutes() : date.getMinutes()
).toLocaleString('en-US', {
minimumIntegerDigits: 2,
useGrouping: false,
maximumFractionDigits: 0,
});
const second = (
utc ? date.getUTCSeconds() : date.getSeconds()
).toLocaleString('en-US', {
minimumIntegerDigits: 2,
useGrouping: false,
maximumFractionDigits: 0,
});
const milliseconds = (
utc ? date.getUTCMilliseconds() : date.getMilliseconds()
).toLocaleString('en-US', {
minimumIntegerDigits: 3,
useGrouping: false,
maximumFractionDigits: 0,
});
return `${year}/${month}/${day} ${hour}:${minute}:${second}${millis ? `.${milliseconds}` : ''}${utc ? 'Z' : ''}`;
}

View File

@@ -12,6 +12,8 @@ export interface GraphData {
border_top: MaybeRefOrGetter<number>;
min_x: MaybeRefOrGetter<number>;
max_x: MaybeRefOrGetter<number>;
live: MaybeRefOrGetter<boolean>;
fetch_history: MaybeRefOrGetter<number>;
width: MaybeRefOrGetter<number>;
height: MaybeRefOrGetter<number>;
x_map: (x: number) => number;

View File

@@ -6,7 +6,7 @@ use crate::http::api::setup_api;
use crate::http::websocket::setup_websocket;
use crate::telemetry::management_service::TelemetryManagementService;
use actix_web::{web, App, HttpServer};
use log::{error, info};
use log::info;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
@@ -29,7 +29,5 @@ pub async fn setup(
.run()
.await?;
error!("http setup end");
Ok(())
}

View File

@@ -1,4 +1,5 @@
use crate::http::websocket::request::{RegisterTlmListenerRequest, WebsocketRequest};
use std::collections::HashMap;
use crate::http::websocket::request::{RegisterTlmListenerRequest, UnregisterTlmListenerRequest, WebsocketRequest};
use crate::http::websocket::response::{TlmValueResponse, WebsocketResponse};
use crate::telemetry::management_service::TelemetryManagementService;
use actix_web::{rt, web, HttpRequest, HttpResponse};
@@ -20,8 +21,13 @@ fn handle_register_tlm_listener(
data: &Arc<TelemetryManagementService>,
request: RegisterTlmListenerRequest,
tx: &Sender<WebsocketResponse>,
tlm_listeners: &mut HashMap<String, CancellationToken>,
) {
if let Some(tlm_data) = data.get_by_uuid(&request.uuid) {
let token = CancellationToken::new();
if let Some(token) = tlm_listeners.insert(tlm_data.definition.uuid.clone(), token.clone()) {
token.cancel();
}
let minimum_separation = Duration::from_millis(request.minimum_separation_ms as u64);
let mut rx = tlm_data.data.subscribe();
let tx = tx.clone();
@@ -35,6 +41,9 @@ fn handle_register_tlm_listener(
_ = tx.closed() => {
break;
}
_ = token.cancelled() => {
break;
}
Ok(_) = rx.changed() => {
let now = Instant::now();
let value = {
@@ -71,15 +80,28 @@ fn handle_register_tlm_listener(
}
}
fn handle_unregister_tlm_listener(
request: UnregisterTlmListenerRequest,
tlm_listeners: &mut HashMap<String, CancellationToken>,
) {
if let Some(token) = tlm_listeners.remove(&request.uuid) {
token.cancel();
}
}
async fn handle_websocket_message(
data: &Arc<TelemetryManagementService>,
request: WebsocketRequest,
tx: &Sender<WebsocketResponse>,
tlm_listeners: &mut HashMap<String, CancellationToken>,
) {
match request {
WebsocketRequest::RegisterTlmListener(request) => {
handle_register_tlm_listener(data, request, tx)
}
handle_register_tlm_listener(data, request, tx, tlm_listeners)
},
WebsocketRequest::UnregisterTlmListener(request) => {
handle_unregister_tlm_listener(request, tlm_listeners)
},
};
}
@@ -105,12 +127,13 @@ async fn handle_websocket_incoming(
data: &Arc<TelemetryManagementService>,
session: &mut Session,
tx: &Sender<WebsocketResponse>,
tlm_listeners: &mut HashMap<String, CancellationToken>,
) -> anyhow::Result<bool> {
match msg {
Ok(AggregatedMessage::Close(_)) => Ok(false),
Ok(AggregatedMessage::Text(msg)) => match serde_json::from_str::<WebsocketRequest>(&msg) {
Ok(request) => {
handle_websocket_message(data, request, tx).await;
handle_websocket_message(data, request, tx, tlm_listeners).await;
Ok(true)
}
Err(err) => Err(anyhow!("JSON Deserialization Error Encountered {err}")),
@@ -141,12 +164,13 @@ pub async fn websocket_connect(
let cancel_token = cancel_token.get_ref().clone();
rt::spawn(async move {
let mut tlm_listeners = HashMap::new();
let (tx, mut rx) = tokio::sync::mpsc::channel::<WebsocketResponse>(128);
loop {
let result = select! {
_ = cancel_token.cancelled() => Ok(false),
Some(msg) = rx.recv() => handle_websocket_response(msg, &mut session).await,
Some(msg) = stream.next() => handle_websocket_incoming(msg, data.get_ref(), &mut session, &tx).await,
Some(msg) = stream.next() => handle_websocket_incoming(msg, data.get_ref(), &mut session, &tx, &mut tlm_listeners).await,
else => Ok(false),
};
match result {
@@ -160,6 +184,9 @@ pub async fn websocket_connect(
}
rx.close();
let _ = session.close(None).await;
for (_, token) in tlm_listeners.drain() {
token.cancel();
}
});
Ok(res)

View File

@@ -7,7 +7,13 @@ pub struct RegisterTlmListenerRequest {
pub minimum_separation_ms: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UnregisterTlmListenerRequest {
pub uuid: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, From)]
pub enum WebsocketRequest {
RegisterTlmListener(RegisterTlmListenerRequest),
UnregisterTlmListener(UnregisterTlmListenerRequest),
}

View File

@@ -38,8 +38,6 @@ pub async fn setup() -> anyhow::Result<()> {
grpc_server.await?; //grpc server is dropped
drop(cancellation_token); // All cancellation tokens are now dropped
error!("after awaits");
// Perform cleanup functions - at this point all servers have stopped and we can be sure that cleaning things up is safe
for _ in 0..15 {
if Arc::strong_count(&tlm) != 1 {