Compare commits

...

10 Commits

51 changed files with 1991 additions and 297 deletions

158
Cargo.lock generated
View File

@@ -49,7 +49,7 @@ dependencies = [
"mime", "mime",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rand", "rand 0.8.5",
"sha1", "sha1",
"smallvec", "smallvec",
"tokio", "tokio",
@@ -221,10 +221,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"getrandom", "getrandom 0.2.15",
"once_cell", "once_cell",
"version_check", "version_check",
"zerocopy", "zerocopy 0.7.35",
] ]
[[package]] [[package]]
@@ -554,18 +554,18 @@ dependencies = [
[[package]] [[package]]
name = "derive_more" name = "derive_more"
version = "1.0.0" version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
dependencies = [ dependencies = [
"derive_more-impl", "derive_more-impl",
] ]
[[package]] [[package]]
name = "derive_more-impl" name = "derive_more-impl"
version = "1.0.0" version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -716,7 +716,19 @@ checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"wasi", "wasi 0.11.0+wasi-snapshot-preview1",
]
[[package]]
name = "getrandom"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8"
dependencies = [
"cfg-if",
"libc",
"wasi 0.13.3+wasi-0.2.2",
"windows-targets",
] ]
[[package]] [[package]]
@@ -1003,9 +1015,9 @@ checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.161" version = "0.2.169"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a"
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
@@ -1042,9 +1054,9 @@ dependencies = [
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.22" version = "0.4.25"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f"
[[package]] [[package]]
name = "matchit" name = "matchit"
@@ -1082,7 +1094,7 @@ dependencies = [
"hermit-abi", "hermit-abi",
"libc", "libc",
"log", "log",
"wasi", "wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
@@ -1124,10 +1136,11 @@ checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
[[package]] [[package]]
name = "papaya" name = "papaya"
version = "0.1.7" version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce63bf9dca3eab259cffd421f05661b3386aee36276f5aed9f71450b98f5c5c" checksum = "dc7c76487f7eaa00a0fc1d7f88dc6b295aec478d11b0fc79f857b62c2874124c"
dependencies = [ dependencies = [
"equivalent",
"seize", "seize",
] ]
@@ -1226,7 +1239,7 @@ version = "0.2.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04"
dependencies = [ dependencies = [
"zerocopy", "zerocopy 0.7.35",
] ]
[[package]] [[package]]
@@ -1250,9 +1263,9 @@ dependencies = [
[[package]] [[package]]
name = "prost" name = "prost"
version = "0.13.4" version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c0fef6c4230e4ccf618a35c59d7ede15dea37de8427500f50aff708806e42ec" checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
dependencies = [ dependencies = [
"bytes", "bytes",
"prost-derive", "prost-derive",
@@ -1281,9 +1294,9 @@ dependencies = [
[[package]] [[package]]
name = "prost-derive" name = "prost-derive"
version = "0.13.4" version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "157c5a9d7ea5c2ed2d9fb8f495b64759f7816c7eaea54ba3978f0d63000162e3" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"itertools", "itertools",
@@ -1317,8 +1330,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [ dependencies = [
"libc", "libc",
"rand_chacha", "rand_chacha 0.3.1",
"rand_core", "rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.0",
"zerocopy 0.8.18",
] ]
[[package]] [[package]]
@@ -1328,7 +1352,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [ dependencies = [
"ppv-lite86", "ppv-lite86",
"rand_core", "rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.0",
] ]
[[package]] [[package]]
@@ -1337,7 +1371,17 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [ dependencies = [
"getrandom", "getrandom 0.2.15",
]
[[package]]
name = "rand_core"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b08f3c9802962f7e1b25113931d94f43ed9725bebc59db9d0c3e9a23b67e15ff"
dependencies = [
"getrandom 0.3.1",
"zerocopy 0.8.18",
] ]
[[package]] [[package]]
@@ -1468,9 +1512,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.134" version = "1.0.138"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d00f4175c42ee48b15416f6193a959ba3a0d67fc699a0db9ad12df9f83991c7d" checksum = "d434192e7da787e94a6ea7e9670b26a036d0ca41e0b7efb2676dd32bae872949"
dependencies = [ dependencies = [
"itoa", "itoa",
"memchr", "memchr",
@@ -1498,13 +1542,13 @@ dependencies = [
"actix-ws", "actix-ws",
"anyhow", "anyhow",
"chrono", "chrono",
"derive_more 1.0.0", "derive_more 2.0.1",
"fern", "fern",
"hex", "hex",
"log", "log",
"papaya", "papaya",
"prost", "prost",
"rand", "rand 0.9.0",
"serde", "serde",
"serde_json", "serde_json",
"thiserror", "thiserror",
@@ -1615,18 +1659,18 @@ dependencies = [
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.9" version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f072643fd0190df67a8bab670c20ef5d8737177d6ac6b2e9a236cb096206b2cc" checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl",
] ]
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "2.0.9" version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b50fa271071aae2e6ee85f842e2e28ba8cd2c5fb67f11fcb1fd70b276f9e7d4" checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -1681,9 +1725,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.42.0" version = "1.43.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551" checksum = "3d61fa4ffa3de412bfea335c6ecff681de2b609ba3c77ef3e00e521813a9ed9e"
dependencies = [ dependencies = [
"backtrace", "backtrace",
"bytes", "bytes",
@@ -1699,9 +1743,9 @@ dependencies = [
[[package]] [[package]]
name = "tokio-macros" name = "tokio-macros"
version = "2.4.0" version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -1787,7 +1831,7 @@ dependencies = [
"indexmap 1.9.3", "indexmap 1.9.3",
"pin-project", "pin-project",
"pin-project-lite", "pin-project-lite",
"rand", "rand 0.8.5",
"slab", "slab",
"tokio", "tokio",
"tokio-util", "tokio-util",
@@ -1919,6 +1963,15 @@ version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "wasi"
version = "0.13.3+wasi-0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2"
dependencies = [
"wit-bindgen-rt",
]
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.95" version = "0.2.95"
@@ -2065,6 +2118,15 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "wit-bindgen-rt"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c"
dependencies = [
"bitflags",
]
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.7.35" version = "0.7.35"
@@ -2072,7 +2134,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
dependencies = [ dependencies = [
"byteorder", "byteorder",
"zerocopy-derive", "zerocopy-derive 0.7.35",
]
[[package]]
name = "zerocopy"
version = "0.8.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79386d31a42a4996e3336b0919ddb90f81112af416270cff95b5f5af22b839c2"
dependencies = [
"zerocopy-derive 0.8.18",
] ]
[[package]] [[package]]
@@ -2086,6 +2157,17 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "zerocopy-derive"
version = "0.8.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76331675d372f91bf8d17e13afbd5fe639200b73d01f0fc748bb059f9cca2db7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "zstd" name = "zstd"
version = "0.13.2" version = "0.13.2"

View File

@@ -6,7 +6,7 @@ edition = "2021"
[dependencies] [dependencies]
server = { path = "../../server" } server = { path = "../../server" }
tonic = "0.12.3" tonic = "0.12.3"
tokio = { version = "1.42.0", features = ["rt-multi-thread", "signal"] } tokio = { version = "1.43.0", features = ["rt-multi-thread", "signal"] }
chrono = "0.4.39" chrono = "0.4.39"
tokio-util = "0.7.13" tokio-util = "0.7.13"
num-traits = "0.2.19" num-traits = "0.2.19"

View File

@@ -13,25 +13,25 @@
"format": "prettier --write src/" "format": "prettier --write src/"
}, },
"dependencies": { "dependencies": {
"sass": "^1.80.3", "sass": "^1.85.0",
"sass-loader": "^16.0.2", "sass-loader": "^16.0.5",
"vue": "^3.5.12", "vue": "^3.5.13",
"vue-router": "^4.4.5" "vue-router": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/node20": "^20.1.4", "@tsconfig/node20": "^20.1.4",
"@types/node": "^20.16.11", "@types/node": "^22.13.4",
"@vitejs/plugin-vue": "^5.1.4", "@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^4.0.1", "@vitejs/plugin-vue-jsx": "^4.1.1",
"@vue/eslint-config-prettier": "^10.0.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.0.1", "@vue/eslint-config-typescript": "^14.4.0",
"@vue/tsconfig": "^0.5.1", "@vue/tsconfig": "^0.7.0",
"eslint": "^9.12.0", "eslint": "^9.20.1",
"eslint-plugin-vue": "^9.29.0", "eslint-plugin-vue": "^9.32.0",
"npm-run-all2": "^6.2.3", "npm-run-all2": "^7.0.2",
"prettier": "^3.3.3", "prettier": "^3.5.1",
"typescript": "~5.5.4", "typescript": "~5.7.3",
"vite": "^5.4.8", "vite": "^6.1.0",
"vue-tsc": "^2.1.6" "vue-tsc": "^2.2.0"
} }
} }

View File

@@ -1,5 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { RouterView } from 'vue-router'; import { RouterView } from 'vue-router';
import { useWebsocket, WEBSOCKET_SYMBOL } from '@/composables/websocket';
import { provide } from 'vue';
const websocket = useWebsocket();
provide(WEBSOCKET_SYMBOL, websocket);
</script> </script>
<template> <template>

View File

@@ -0,0 +1,112 @@
.column {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
align-content: flex-start;
}
.align-start {
align-items: flex-start;
}
.align-center {
align-items: center;
}
.align-end {
align-items: flex-end;
}
.justify-start {
justify-content: flex-start;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.justify-end {
justify-content: flex-end;
}
.gap-half {
gap: 0.0625em;
}
.gap-full {
gap: 0.125em;
}
.gap-wide {
gap: 0.25em;
}
.row {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-content: center;
}
.center {
justify-content: center;
align-items: center;
align-content: center;
}
.content {
padding: 20vh 10vw;
}
.content.page {
max-width: 1200px;
}
.content.wide {
padding-left: 5vw;
padding-right: 5vw;
}
.content.tall {
padding-top: 5vw;
padding-bottom: 5vw;
}
.stretch {
align-items: stretch;
}
.grow {
flex-grow: 1;
}
.grow2 {
flex-grow: 2;
}
.wrap {
flex-wrap: wrap;
}
.no-basis {
flex-basis: 0;
}
.no-min-height {
min-height: 0;
}
.no-min-width {
min-width: 0;
}
.scroll {
overflow: auto;
}
.grid {
display: grid;
grid-auto-flow: row;
grid-auto-rows: 1fr;
row-gap: 0.125em;
column-gap: 0.5em;
justify-items: stretch;
align-items: center;
}

View File

@@ -4,17 +4,42 @@
body { body {
color: variables.$text-color; color: variables.$text-color;
font-size: variables.$normal-text-size; font-size: variables.$normal-text-size;
font-family: variables.$text-font;
background-color: variables.$background-color; background-color: variables.$background-color;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: stretch;
align-items: center; align-items: stretch;
align-content: center; align-content: center;
min-height: 100vh; min-height: 100vh;
margin: 0;
stroke-width: 0;
}
main {
flex-grow: 1;
}
#app {
display: flex;
flex-direction: row;
justify-content: center;
justify-items: center;
align-content: center;
align-items: center;
}
#app > div {
margin-left: auto;
margin-right: auto;
} }
* { * {
stroke-width: 0; box-sizing: border-box;
}
.hidden {
visibility: hidden;
} }
polyline { polyline {
@@ -26,3 +51,11 @@ polyline {
#{--indexed-color}: list.nth(variables.$colors, $i); #{--indexed-color}: list.nth(variables.$colors, $i);
} }
} }
a,
a:visited,
a:hover,
a:active {
text-decoration: inherit;
color: inherit;
}

View File

@@ -27,8 +27,10 @@ $magenta-2: oklch(75% 0.2 330);
$text-color: $gray-1; $text-color: $gray-1;
$background-color: $gray-7; $background-color: $gray-7;
$light2-background-color: color.adjust($background-color, $lightness: 10%);
$light-background-color: color.adjust($background-color, $lightness: 5%); $light-background-color: color.adjust($background-color, $lightness: 5%);
$dark-background-color: color.adjust($background-color, $lightness: -5%); $dark-background-color: color.adjust($background-color, $lightness: -5%);
$dark2-background-color: color.adjust($background-color, $lightness: -10%);
$cursor-tick: $gray-0; $cursor-tick: $gray-0;
$time-tick: $gray-1; $time-tick: $gray-1;

View File

@@ -0,0 +1,113 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, useTemplateRef } from 'vue';
const props = defineProps<{
value: string;
}>();
const overlay = computed(() => {
return '0'.repeat(props.value.length);
});
const span = useTemplateRef<HTMLSpanElement>('data-span');
function copyHandler(event: ClipboardEvent) {
const selection = document.getSelection();
const span_value: HTMLSpanElement | null = span.value;
if (selection && span_value && selection.containsNode(span_value, true)) {
let copy_result = '';
for (let i = 0; i < selection.rangeCount; i++) {
const node_iter = document.createNodeIterator(
selection.getRangeAt(i).commonAncestorContainer,
NodeFilter.SHOW_ALL,
);
let found_start = false;
while (true) {
const node = node_iter.nextNode();
if (node) {
if (node == selection?.getRangeAt(i).startContainer) {
found_start = true;
}
if (found_start && node.nodeType == Node.TEXT_NODE) {
let append_to_copy = node.textContent?.trim() || '';
const parent = node.parentElement;
if (parent) {
const copy_value =
parent.getAttribute('copy-value');
if (copy_value) {
append_to_copy = copy_value;
}
}
if (node == selection?.getRangeAt(i).endContainer) {
append_to_copy = append_to_copy.substring(
0,
selection?.getRangeAt(i).endOffset,
);
}
if (node == selection?.getRangeAt(i).startContainer) {
append_to_copy = append_to_copy.substring(
selection?.getRangeAt(i).startOffset,
);
}
if (copy_result.length > 0) {
copy_result += ' ';
}
copy_result += append_to_copy;
}
if (node == selection?.getRangeAt(i).endContainer) {
break;
}
} else {
break;
}
}
}
event.clipboardData?.setData('text/plain', copy_result);
event.preventDefault();
event.stopImmediatePropagation();
}
}
onMounted(() => {
document.body.addEventListener('copy', copyHandler);
});
onUnmounted(() => {
document.body.removeEventListener('copy', copyHandler);
});
</script>
<template>
<span class="test monospace" :data-after-content="value">
<span ref="data-span" class="transparent" :copy-value="value">
{{ overlay }}
</span>
</span>
</template>
<style scoped lang="scss">
@use '@/assets/variables';
.monospace {
font-family: variables.$monospace-text-font;
}
.transparent {
color: transparent;
border: transparent;
background: transparent;
}
.test {
position: relative;
}
.test::after {
content: attr(data-after-content);
position: absolute;
top: 0;
left: 0;
height: 0;
width: 0;
}
</style>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts"></script>
<template>
<div></div>
</template>
<style scoped lang="scss">
@use '@/assets/variables';
div {
align-self: stretch;
border: 1px solid variables.$major-tick;
}
</style>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
defineProps<{
icon: string;
}>();
</script>
<template>
<i :class="`icon ${icon}`"></i>
</template>
<style scoped lang="scss">
@use '@/assets/variables';
.icon {
display: inline-block;
height: 1em;
}
.icon:before {
display: inline-block;
content: ' ';
background-color: variables.$text-color;
mask-size: cover;
width: 1em;
height: 1em;
}
.icon.hamburger.menu:before {
mask-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9IjAgMCAxMCAxMCIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlV2lkdGg9IjFweCI+PGxpbmUgeDE9IjAiIHkxPSIxIiB4Mj0iMTAiIHkyPSIxIj48L2xpbmU+PGxpbmUgeDE9IjAiIHkxPSI1IiB4Mj0iMTAiIHkyPSI1Ij48L2xpbmU+PGxpbmUgeDE9IjAiIHkxPSI5IiB4Mj0iMTAiIHkyPSI5Ij48L2xpbmU+PC9zdmc+IA==');
}
.icon.close:before {
mask-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9IjAgMCAxMCAxMCIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlV2lkdGg9IjFweCI+PGxpbmUgeDE9IjAiIHkxPSIwIiB4Mj0iMTAiIHkyPSIxMCI+PC9saW5lPjxsaW5lIHgxPSIxMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMTAiPjwvbGluZT48L3N2Zz4g');
}
.icon.up.arrow:before {
mask-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9IjAgMCAxMCAxMCIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlV2lkdGg9IjFweCI+PGxpbmUgeDE9IjAiIHkxPSIxMCIgeDI9IjUiIHkyPSIwIj48L2xpbmU+PGxpbmUgeDE9IjUiIHkxPSIwIiB4Mj0iMTAiIHkyPSIxMCI+PC9saW5lPjwvc3ZnPg==');
}
.icon.down.arrow:before {
mask-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9IjAgMCAxMCAxMCIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlV2lkdGg9IjFweCI+PGxpbmUgeDE9IjAiIHkxPSIwIiB4Mj0iNSIgeTI9IjEwIj48L2xpbmU+PGxpbmUgeDE9IjUiIHkxPSIxMCIgeDI9IjEwIiB5Mj0iMCI+PC9saW5lPjwvc3ZnPg==');
}
/*
<svg xmlns='http://www.w3.org/2000/svg' viewBox="0 0 10 10" stroke="white" strokeWidth="1px">
<line x1="0" y1="0" x2="5" y2="10"></line>
<line x1="5" y1="10" x2="10" y2="0"></line>
</svg>
*/
</style>

View File

@@ -1,14 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, watch } from 'vue'; import { computed, watch } from 'vue';
import CopyableDynamicSpan from '@/components/CopyableDynamicSpan.vue';
const props = defineProps<{ const props = defineProps<{
value: number; value: number;
max_width: number; max_width: number;
copyable?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update', value: string): void; (e: 'update', value: string): void;
}>(); }>();
const copyable = computed(() => {
return props.copyable || false;
});
const display_value = computed(() => { const display_value = computed(() => {
if (props.value == 0) { if (props.value == 0) {
return '0'; return '0';
@@ -61,7 +67,12 @@ watch([display_value], ([display_str]) => {
</script> </script>
<template> <template>
<template v-if="copyable">
<CopyableDynamicSpan :value="display_value"></CopyableDynamicSpan>
</template>
<template v-else>
{{ display_value }} {{ display_value }}
</template>
</template> </template>
<style scoped lang="scss"></style> <style scoped lang="scss"></style>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import {
type PanelHeirarchyChildren,
PanelHeirarchyType,
} from '@/panels/panel';
defineProps<{
heirarchy: PanelHeirarchyChildren;
}>();
</script>
<template>
<ul>
<li v-for="child in heirarchy" :key="child.name">
<RouterLink
v-if="child.type == PanelHeirarchyType.LEAF"
:to="child.to"
>
{{ child.name }}
</RouterLink>
<template v-if="child.type == PanelHeirarchyType.FOLDER">
{{ child.name }}
<PanelHeirarchy :heirarchy="child.children"> </PanelHeirarchy>
</template>
</li>
</ul>
</template>
<style scoped lang="scss">
@use '@/assets/variables';
</style>

View File

@@ -11,10 +11,15 @@ import {
import { useNow } from '@/composables/ticker'; import { useNow } from '@/composables/ticker';
import { GRAPH_DATA, type GraphData, GraphSide } from '@/graph/graph'; import { GRAPH_DATA, type GraphData, GraphSide } from '@/graph/graph';
import TimeText from '@/components/TimeText.vue'; import TimeText from '@/components/TimeText.vue';
import { getDateString } from '@/datetime'; import {
getDateString,
getDurationString,
parseDurationString,
} from '@/datetime';
import { onDocumentShown } from '@/composables/document.ts';
const props = defineProps<{ const props = defineProps<{
duration?: number; initial_duration?: number;
utc?: boolean; utc?: boolean;
left_axis?: boolean; left_axis?: boolean;
right_axis?: boolean; right_axis?: boolean;
@@ -28,21 +33,27 @@ const props = defineProps<{
const divRef = useTemplateRef<HTMLDivElement>('graph-div'); const divRef = useTemplateRef<HTMLDivElement>('graph-div');
const width = ref(0); const width = ref(0);
const height = ref(0); const raw_height = ref(0);
const controls_height = 32; const controls_height = 32;
const min_time_label_separation = 250;
const resize_observer = new ResizeObserver((elements) => { const resize_observer = new ResizeObserver((elements) => {
for (const element of elements) { for (const element of elements) {
if (element.target == divRef.value) { if (element.target == divRef.value) {
width.value = element.contentBoxSize[0].inlineSize; width.value = element.contentBoxSize[0].inlineSize;
height.value = raw_height.value = element.contentBoxSize[0].blockSize;
element.contentBoxSize[0].blockSize -
(props.include_controls ? controls_height : 0);
} }
} }
}); });
const height = computed(() => {
return Math.max(
raw_height.value - (props.include_controls ? controls_height : 0),
1,
);
});
watch([divRef], ([divRef]) => { watch([divRef], ([divRef]) => {
if (divRef) { if (divRef) {
resize_observer.observe(divRef); resize_observer.observe(divRef);
@@ -55,13 +66,10 @@ onUnmounted(() => {
resize_observer.disconnect(); resize_observer.disconnect();
}); });
const now = useNow(33); const update_ms = 33;
const window_duration = computed(() => {
if (props.duration) { const now = useNow(update_ms);
return props.duration; const window_duration = ref(props.initial_duration || 10 * 1000);
}
return 10 * 1000; // 10 seconds
});
const time_lines = [ const time_lines = [
1, // 1ms 1, // 1ms
@@ -79,6 +87,7 @@ const time_lines = [
10000, // 10s 10000, // 10s
30000, // 30s 30000, // 30s
60000, // 1m 60000, // 1m
150000, // 2.5m
300000, // 5m 300000, // 5m
6000000, // 10m 6000000, // 10m
18000000, // 30m 18000000, // 30m
@@ -91,7 +100,7 @@ const time_lines = [
1728000000, // 2d 1728000000, // 2d
6048000000, // 1w 6048000000, // 1w
]; ];
time_lines.reverse(); // time_lines.reverse();
const text_offset = computed(() => 5); const text_offset = computed(() => 5);
const legend_width = 160; const legend_width = 160;
@@ -129,6 +138,10 @@ watch([now], ([now_value]) => {
} }
}); });
onDocumentShown(() => {
fetch_history.value++;
});
const max_x_text = computed({ const max_x_text = computed({
// getter // getter
get() { get() {
@@ -148,6 +161,18 @@ const max_x_text = computed({
} }
}, },
}); });
const duration_text = computed({
get() {
return getDurationString(window_duration.value);
},
set(newValue) {
const new_duration = parseDurationString(newValue);
if (!Number.isNaN(new_duration)) {
window_duration.value = Math.max(new_duration, 1);
fetch_history.value++;
}
},
});
const x_map = (x: number) => { const x_map = (x: number) => {
return ( return (
@@ -178,16 +203,25 @@ const legend_x_stride = computed(() => 0);
const legend_y_stride = computed(() => 16); const legend_y_stride = computed(() => 16);
const legend_width_output = computed(() => legend_width - 8); const legend_width_output = computed(() => legend_width - 8);
const graph_width = computed(() => {
return Math.max(width.value - border_left.value - border_right.value, 1);
});
const line_duration = computed(() => { const line_duration = computed(() => {
const width_px = graph_width.value;
const diff_x = max_x.value - min_x.value; const diff_x = max_x.value - min_x.value;
return time_lines.find((duration) => diff_x / duration >= 2)!; return time_lines.find((duration) => {
const line_count = diff_x / duration;
const width_per_line = width_px / line_count;
return width_per_line >= min_time_label_separation;
})!;
}); });
const lines = computed(() => { const lines = computed(() => {
const result = []; const result = [];
for ( for (
let i = Math.ceil(max_x.value / line_duration.value); let i = Math.ceil(max_x.value / line_duration.value);
i >= Math.ceil(min_x.value / line_duration.value) - 5; i >= Math.floor(min_x.value / line_duration.value);
i-- i--
) { ) {
const x = i * line_duration.value; const x = i * line_duration.value;
@@ -200,13 +234,9 @@ const mouse_x = ref<number | null>(null);
function onMouseMove(event: MouseEvent) { function onMouseMove(event: MouseEvent) {
if (props.cursor) { if (props.cursor) {
const new_x = mouse_x.value =
event.clientX - event.clientX -
(event.currentTarget as Element).getBoundingClientRect().left; (event.currentTarget as Element).getBoundingClientRect().left;
if (new_x == 0) {
console.log(event);
}
mouse_x.value = new_x;
} }
} }
@@ -220,8 +250,6 @@ const mouse_t = computed(() => {
} }
const t = inv_x_map(mouse_x.value); const t = inv_x_map(mouse_x.value);
if (t < min_x.value || t > max_x.value) { if (t < min_x.value || t > max_x.value) {
// console.log(`x ${mouse_x.value} t ${t} min ${t < min_x.value} max ${t > max_x.value}`)
// debugger;
return null; return null;
} }
return t; return t;
@@ -229,7 +257,7 @@ const mouse_t = computed(() => {
const show_data_at_time = computed(() => { const show_data_at_time = computed(() => {
if (mouse_t.value === null) { if (mouse_t.value === null) {
return max_x.value; return max_x.value + update_ms;
} else { } else {
return mouse_t.value; return mouse_t.value;
} }
@@ -237,14 +265,19 @@ const show_data_at_time = computed(() => {
const should_fade = ref(false); const should_fade = ref(false);
const max_temporal_resolution = computed(() => {
const delta_t = window_duration.value;
return Math.floor(delta_t / 1000); // Aim for a maximum of 1000 data points
});
provide<GraphData>(GRAPH_DATA, { provide<GraphData>(GRAPH_DATA, {
border_top: border_top, border_top: border_top,
min_x: min_x, min_x: min_x,
max_x: max_x, max_x: max_x,
max_temporal_resolution: max_temporal_resolution,
live: live, live: live,
fetch_history: fetch_history, fetch_history: fetch_history,
width: () => width: graph_width,
Math.max(width.value - border_left.value - border_right.value, 0),
height: () => height: () =>
Math.max(height.value - border_top.value - border_bottom.value, 0), Math.max(height.value - border_top.value - border_bottom.value, 0),
x_map: x_map, x_map: x_map,
@@ -269,6 +302,14 @@ provide<GraphData>(GRAPH_DATA, {
:style="`height: ${controls_height}px`" :style="`height: ${controls_height}px`"
> >
<div class="grow"></div> <div class="grow"></div>
<div>
<input
type="text"
autocomplete="off"
:size="15"
v-model="duration_text"
/>
</div>
<div> <div>
<input <input
type="text" type="text"
@@ -335,6 +376,7 @@ provide<GraphData>(GRAPH_DATA, {
:timestamp="tick" :timestamp="tick"
:utc="props.utc" :utc="props.utc"
:show_millis="line_duration < 1000" :show_millis="line_duration < 1000"
:key="tick"
></TimeText> ></TimeText>
</template> </template>
</g> </g>
@@ -393,6 +435,7 @@ provide<GraphData>(GRAPH_DATA, {
div.full-size { div.full-size {
width: 100%; width: 100%;
height: 100%; height: 100%;
position: relative;
} }
div.controls-header { div.controls-header {
@@ -401,11 +444,11 @@ div.controls-header {
justify-content: center; justify-content: center;
align-items: center; align-items: center;
align-content: stretch; align-content: stretch;
gap: 1em 0; gap: 0 1em;
margin: 0 1em 0 1em; margin: 0 1em 0 1em;
} }
div.controls-header > div.grow { svg.graph {
flex-grow: 1; position: absolute;
} }
</style> </style>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import type { TelemetryDefinition } from '@/composables/telemetry';
import Line from '@/components/TelemetryLine.vue';
import SvgGraph from '@/components/SvgGraph.vue';
import Axis from '@/components/GraphAxis.vue';
import { computed } from 'vue';
const props = defineProps<{
selection: TelemetryDefinition | null;
mouseover: TelemetryDefinition | null;
}>();
const telemetry_definition = computed(() => {
if (props.mouseover) {
return props.mouseover;
}
return props.selection;
});
const secondary = computed(() => {
if (props.mouseover) {
if (props.selection) {
if (props.selection.uuid != props.mouseover.uuid) {
return props.selection;
}
}
}
return null;
});
const lines = computed(() => {
const result = [];
if (secondary.value) {
result.push(secondary.value);
}
if (telemetry_definition.value) {
result.push(telemetry_definition.value);
}
return result;
});
</script>
<template>
<div class="row">
<span>
{{ telemetry_definition?.name || 'No Telemetry Selected' }}
</span>
</div>
<div :class="`row ${telemetry_definition == null ? 'hidden' : ''}`">
<span>
{{ telemetry_definition?.uuid || '0' }}
</span>
</div>
<div :class="`row ${telemetry_definition == null ? 'hidden' : ''}`">
<span>
{{ telemetry_definition?.data_type || '0' }}
</span>
</div>
<div class="row graph grow no-min-height no-basis">
<SvgGraph right_axis>
<Axis>
<Line
v-for="line in lines"
:data="line.name"
:key="line.uuid"
></Line>
</Axis>
</SvgGraph>
</div>
</template>
<style scoped lang="scss">
.graph {
min-height: 50px;
}
.hidden {
visibility: hidden;
}
</style>

View File

@@ -37,18 +37,26 @@ const legend_text_offset = 4;
const marker_radius = 3; const marker_radius = 3;
const text_offset = computed(() => 10); const text_offset = computed(() => 10);
const min_sep = computed(() =>
Math.min(props.minimum_separation || 0, maximum_minimum_separation_live),
);
const graph_data = inject<GraphData>(GRAPH_DATA)!; const graph_data = inject<GraphData>(GRAPH_DATA)!;
const axis_data = inject<AxisData>(AXIS_DATA)!; const axis_data = inject<AxisData>(AXIS_DATA)!;
const data_min_sep = computed(() => {
return Math.max(
props.minimum_separation || 0,
toValue(graph_data.max_temporal_resolution),
);
});
const live_min_sep = computed(() =>
Math.min(data_min_sep.value, maximum_minimum_separation_live),
);
const { data: telemetry_data } = useTelemetry(() => props.data); const { data: telemetry_data } = useTelemetry(() => props.data);
const websocket = inject<ShallowRef<WebsocketHandle>>(WEBSOCKET_SYMBOL)!; const websocket = inject<ShallowRef<WebsocketHandle>>(WEBSOCKET_SYMBOL)!;
const value = websocket.value.listen_to_telemetry( const value = websocket.value.listen_to_telemetry(
telemetry_data, telemetry_data,
min_sep, live_min_sep,
graph_data.live, graph_data.live,
); );
@@ -95,7 +103,7 @@ watch([value], ([val]) => {
x: val_t, x: val_t,
y: item_val, y: item_val,
} as Point; } as Point;
memo.value.insert(new_item, props.minimum_separation); memo.value.insert(new_item, data_min_sep.value);
if (item_val < min.value) { if (item_val < min.value) {
min.value = item_val; min.value = item_val;
} }
@@ -118,7 +126,7 @@ watch(
const min_x = new Date(toValue(graph_data.min_x)); const min_x = new Date(toValue(graph_data.min_x));
const max_x = new Date(toValue(graph_data.max_x)); const max_x = new Date(toValue(graph_data.max_x));
const res = await fetch( const res = await fetch(
`/api/tlm/history/${uuid}?from=${min_x.toISOString()}&to=${max_x.toISOString()}&resolution=${props.minimum_separation || 0}`, `/api/tlm/history/${uuid}?from=${min_x.toISOString()}&to=${max_x.toISOString()}&resolution=${data_min_sep.value}`,
); );
const response = (await res.json()) as TelemetryDataItem[]; const response = (await res.json()) as TelemetryDataItem[];
for (const data_item of response) { for (const data_item of response) {
@@ -136,9 +144,7 @@ watch(
max.value = item_val; max.value = item_val;
} }
} }
memo.value.reduce_to_maximum_separation( memo.value.reduce_to_maximum_separation(data_min_sep.value);
props.minimum_separation || 0,
);
triggerRef(memo); triggerRef(memo);
debounced_recompute(); debounced_recompute();
recompute_bounds.value++; recompute_bounds.value++;
@@ -383,7 +389,7 @@ function onMouseExit(event: MouseEvent) {
</g> </g>
<ValueLabel <ValueLabel
v-if="current_data_point" v-if="current_data_point"
class="fade_other_selected" class="fade_other_selected label"
:x="graph_data.x_map(toValue(graph_data.max_x)) + text_offset" :x="graph_data.x_map(toValue(graph_data.max_x)) + text_offset"
:y="axis_data.y_map(current_data_point.y)" :y="axis_data.y_map(current_data_point.y)"
:value="current_data_point.y" :value="current_data_point.y"
@@ -438,6 +444,11 @@ function onMouseExit(event: MouseEvent) {
<div class="row"> <div class="row">
<span>{{ telemetry_data?.data_type }}</span> <span>{{ telemetry_data?.data_type }}</span>
</div> </div>
<div class="row">
<span>{{
current_data_point?.y || 'Missing Data'
}}</span>
</div>
</template> </template>
<template v-else> <template v-else>
<div class="row"> <div class="row">
@@ -462,6 +473,10 @@ function onMouseExit(event: MouseEvent) {
opacity: 25%; opacity: 25%;
} }
.fade .fade_other_selected.label {
opacity: 0;
}
.fade .no-fade .fade_other_selected { .fade .no-fade .fade_other_selected {
opacity: 100%; opacity: 100%;
} }
@@ -514,21 +529,8 @@ rect.legend:has(~ .legend:hover) {
cursor: help; cursor: help;
} }
div.column { div.column,
display: flex;
flex-flow: column nowrap;
justify-content: flex-start;
align-items: flex-start;
align-content: flex-start;
gap: 0.25em;
}
div.row { div.row {
display: flex;
flex-flow: row nowrap;
justify-content: flex-start;
align-items: center;
align-content: center;
gap: 0.25em; gap: 0.25em;
} }

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import {
type TelemetryDefinition,
useAllTelemetry,
} from '@/composables/telemetry';
import { computed, ref, watch } from 'vue';
const props = defineProps<{
search?: string;
}>();
const selected = defineModel<TelemetryDefinition | null>();
const emit = defineEmits<{
(e: 'mouseover', tlm_entry: TelemetryDefinition | null): void;
}>();
const search_value = computed(() => (props.search || '').toLowerCase());
const { data: telemetry_data } = useAllTelemetry();
const sorted_tlm_data = computed(() => {
const tlm_data = telemetry_data.value;
if (tlm_data != null) {
return tlm_data
.filter((entry) =>
entry.name.toLowerCase().includes(search_value.value),
)
.sort((a, b) => a.name.localeCompare(b.name));
}
return [];
});
const mousedover = ref<TelemetryDefinition | null>(null);
function onMouseover(tlm_entry: TelemetryDefinition) {
mousedover.value = tlm_entry;
}
function onMouseleave() {
mousedover.value = null;
}
function onClick(tlm_entry: TelemetryDefinition) {
selected.value = tlm_entry;
}
watch([mousedover], ([mousedover_val]) => {
emit('mouseover', mousedover_val);
});
</script>
<template>
<template v-if="sorted_tlm_data.length > 0">
<div
v-for="tlm_entry in sorted_tlm_data"
:class="`row data ${selected?.uuid == tlm_entry.uuid ? 'selected' : ''}`"
:key="tlm_entry.uuid"
@mouseover="() => onMouseover(tlm_entry)"
@mouseleave="() => onMouseleave()"
@click="() => onClick(tlm_entry)"
>
<span>
{{ tlm_entry.name }}
</span>
</div>
</template>
<template v-else>
<div class="row">
<span> No Matches Found </span>
</div>
</template>
</template>
<style scoped lang="scss">
@use '@/assets/variables';
div {
padding: 0.3em;
border: 0;
border-bottom: variables.$gray-3 solid 1px;
border-top: 0;
}
.data.selected:has(~ .data:hover),
.data:hover ~ .data.selected {
background-color: variables.$light-background-color;
}
.data.selected,
.data:hover {
background-color: variables.$light2-background-color;
}
</style>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { useTelemetry } from '@/composables/telemetry.ts';
import { computed, inject, type ShallowRef } from 'vue';
import {
WEBSOCKET_SYMBOL,
type WebsocketHandle,
} from '@/composables/websocket.ts';
import NumericText from '@/components/NumericText.vue';
const max_update_rate = 50; // ms
const default_update_rate = 200; // ms
const props = defineProps<{
data: string;
max_update_period?: number;
}>();
const max_update_period = computed(() => {
return Math.min(
props.max_update_period || default_update_rate,
max_update_rate,
);
});
const { data: telemetry_data } = useTelemetry(() => props.data);
const websocket = inject<ShallowRef<WebsocketHandle>>(WEBSOCKET_SYMBOL)!;
const value = websocket.value.listen_to_telemetry(
telemetry_data,
max_update_period,
true,
);
const is_data_present = computed(() => {
return value.value != null;
});
const numeric_data = computed(() => {
const val = value.value;
if (val) {
const type = telemetry_data.value!.data_type;
const item_val = val.value[type];
if (typeof item_val == 'number') {
return item_val;
}
}
return null;
});
</script>
<template>
<span v-if="!is_data_present"> No Data </span>
<template v-else>
<NumericText
v-if="numeric_data"
:value="numeric_data"
:max_width="10"
></NumericText>
<span v-else>
Cannot Display Data of Type {{ telemetry_data!.data_type }}
</span>
</template>
</template>
<style scoped lang="scss">
@use '@/assets/variables';
</style>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import { onMounted, useTemplateRef } from 'vue';
const props = defineProps<{
autofocus?: boolean;
placeholder?: string;
}>();
const model = defineModel();
const emit = defineEmits<{
(e: 'enter'): void;
}>();
const inputRef = useTemplateRef<HTMLInputElement>('input-ref');
onMounted(() => {
if (props.autofocus) {
inputRef.value?.focus();
}
});
</script>
<template>
<input
ref="input-ref"
class="grow"
v-model="model"
:placeholder="placeholder"
v-on:keyup.enter="emit('enter')"
/>
</template>
<style scoped lang="scss">
@use '@/assets/variables';
input {
appearance: none;
color: inherit;
background-color: inherit;
border: 0 solid variables.$gray-3;
border-bottom: 1px solid variables.$gray-3;
padding: 0.5ex;
font-size: inherit;
outline: none;
min-width: 5em;
}
</style>

View File

@@ -20,7 +20,7 @@ const height = ref(0);
function update_for_element(element: Element) { function update_for_element(element: Element) {
top.value = element.getBoundingClientRect().top + window.scrollY; top.value = element.getBoundingClientRect().top + window.scrollY;
left.value = element.getBoundingClientRect().left + window.screenX; left.value = element.getBoundingClientRect().left + window.scrollX;
width.value = element.getBoundingClientRect().width; width.value = element.getBoundingClientRect().width;
height.value = element.getBoundingClientRect().height; height.value = element.getBoundingClientRect().height;
} }

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
defineProps<{
cols: number;
equal_col_width?: boolean;
}>();
</script>
<template>
<div
:class="`grid`"
:style="`grid-template-columns: repeat(${cols}, ${equal_col_width ? '1fr' : 'auto'});`"
>
<slot></slot>
</div>
</template>
<style scoped lang="scss">
@use '@/assets/variables';
</style>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { Direction } from '@/composables/Direction.ts';
import { Alignment } from '@/composables/Alignment.ts';
import { Justification } from '@/composables/Justification.ts';
import { computed } from 'vue';
import { GapSize } from '@/composables/GapSize.ts';
const props = defineProps<{
direction: Direction;
stretch?: boolean;
// Direction
justify?: Justification;
// Cross Direction
align?: Alignment;
gap?: GapSize;
}>();
const justification = computed(() => props.justify || Justification.Start);
const alignment = computed(() => props.align || Alignment.Start);
const gap_size = computed(() => props.gap || GapSize.Normal);
</script>
<template>
<div
:class="`${direction} linear ${stretch ? 'stretch' : ''} ${justification} ${alignment} ${gap_size}`"
>
<slot></slot>
</div>
</template>
<style scoped lang="scss">
@use '@/assets/variables';
</style>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { ScreenType } from '@/composables/ScreenType.ts';
defineProps<{
// Whether this should be limited to the height of the viewport
// This allows scroll bars to be pushed to inner components
limit?: boolean;
type: ScreenType;
}>();
</script>
<template>
<div
:class="`column stretch screen ${limit ? 'limited' : ''} content ${type}`"
>
<slot></slot>
</div>
</template>
<style scoped lang="scss">
@use '@/assets/variables';
.screen {
min-height: 100vh;
flex-grow: 1;
}
.screen.limited {
max-height: 100vh;
}
</style>

View File

@@ -0,0 +1,5 @@
export enum Alignment {
Start = 'align-start',
Center = 'align-center',
End = 'align-end',
}

View File

@@ -0,0 +1,4 @@
export enum Direction {
Row = 'row',
Column = 'column',
}

View File

@@ -0,0 +1,6 @@
export enum GapSize {
None = '',
Thin = 'gap-half',
Normal = 'gap-full',
Wide = 'gap-wide',
}

View File

@@ -0,0 +1,6 @@
export enum Justification {
Start = 'justify-start',
Center = 'justify-center',
Between = 'justify-between',
End = 'justify-end',
}

View File

@@ -0,0 +1,7 @@
export enum ScreenType {
Standard = '',
Page = 'page',
Wide = 'wide',
Tall = 'tall',
WideTall = 'wide tall',
}

View File

@@ -0,0 +1,24 @@
import { onMounted, onUnmounted, ref } from 'vue';
export function onDocumentVisibilityChange(
handler: (visible: boolean) => void,
) {
const handlerRef = ref(() => {
handler(!document.hidden);
});
onMounted(() => {
document.addEventListener('visibilitychange', handlerRef.value);
});
onUnmounted(() => {
document.removeEventListener('visibilitychange', handlerRef.value);
});
}
export function onDocumentShown(handler: () => void) {
onDocumentVisibilityChange((visible) => {
if (visible) {
handler();
}
});
}

View File

@@ -7,6 +7,25 @@ export interface TelemetryDefinition {
data_type: string; data_type: string;
} }
export function useAllTelemetry() {
const data = ref<TelemetryDefinition[] | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const error = ref<any | null>(null);
watchEffect(async () => {
try {
const res = await fetch(`/api/tlm/info`);
data.value = await res.json();
error.value = null;
} catch (e) {
data.value = null;
error.value = e;
}
});
return { data, error };
}
export function useTelemetry(name: MaybeRefOrGetter<string>) { export function useTelemetry(name: MaybeRefOrGetter<string>) {
const data = ref<TelemetryDefinition | null>(null); const data = ref<TelemetryDefinition | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@@ -11,6 +11,7 @@ import {
watch, watch,
} from 'vue'; } from 'vue';
import type { TelemetryDefinition } from '@/composables/telemetry'; import type { TelemetryDefinition } from '@/composables/telemetry';
import { onDocumentVisibilityChange } from '@/composables/document.ts';
export interface TelemetryDataItem { export interface TelemetryDataItem {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -27,12 +28,14 @@ export class WebsocketHandle {
websocket: WebSocket | null; websocket: WebSocket | null;
should_be_connected: boolean; should_be_connected: boolean;
connected: Ref<boolean>; connected: Ref<boolean>;
enabled: Ref<boolean>;
on_telem_value: Map<string, Array<(value: TelemetryDataItem) => void>>; on_telem_value: Map<string, Array<(value: TelemetryDataItem) => void>>;
constructor() { constructor() {
this.websocket = null; this.websocket = null;
this.should_be_connected = false; this.should_be_connected = false;
this.connected = ref(false); this.connected = ref(false);
this.enabled = ref(true);
this.on_telem_value = new Map(); this.on_telem_value = new Map();
} }
@@ -119,9 +122,9 @@ export class WebsocketHandle {
const is_live = computed(() => toValue(live)); const is_live = computed(() => toValue(live));
watch( watch(
[uuid, this.connected, minimum_separation, is_live], [uuid, this.connected, this.enabled, minimum_separation, is_live],
([uuid_value, connected, min_sep, live_value]) => { ([uuid_value, connected, enabled, min_sep, live_value]) => {
if (connected && uuid_value && live_value) { if (connected && enabled && uuid_value && live_value) {
this.websocket?.send( this.websocket?.send(
JSON.stringify({ JSON.stringify({
RegisterTlmListener: { RegisterTlmListener: {
@@ -160,6 +163,14 @@ export class WebsocketHandle {
return value_result; return value_result;
} }
pause() {
this.enabled.value = false;
}
resume() {
this.enabled.value = true;
}
} }
export const WEBSOCKET_SYMBOL = Symbol(); export const WEBSOCKET_SYMBOL = Symbol();
@@ -170,6 +181,13 @@ export function useWebsocket() {
onMounted(() => { onMounted(() => {
handle.value.connect(); handle.value.connect();
}); });
onDocumentVisibilityChange((visible) => {
if (visible) {
handle.value.resume();
} else {
handle.value.pause();
}
});
onUnmounted(() => { onUnmounted(() => {
handle.value.disconnect(); handle.value.disconnect();
}); });

View File

@@ -1,5 +1,9 @@
// This function is slow // This function is slow
export function getDateString(date: Date, utc: boolean, millis: boolean) { export function getDateString(
date: Date,
utc: boolean,
millis: boolean,
): string {
const year = utc ? date.getUTCFullYear() : date.getFullYear(); const year = utc ? date.getUTCFullYear() : date.getFullYear();
const month = ( const month = (
(utc ? date.getMonth() : date.getMonth()) + 1 (utc ? date.getMonth() : date.getMonth()) + 1
@@ -47,3 +51,47 @@ export function getDateString(date: Date, utc: boolean, millis: boolean) {
}); });
return `${year}/${month}/${day} ${hour}:${minute}:${second}${millis ? `.${milliseconds}` : ''}${utc ? 'Z' : ''}`; return `${year}/${month}/${day} ${hour}:${minute}:${second}${millis ? `.${milliseconds}` : ''}${utc ? 'Z' : ''}`;
} }
export function getDurationString(duration_millis: number): string {
const millis = duration_millis % 1000;
const duration_secs = Math.floor(duration_millis / 1000);
const seconds = duration_secs % 60;
const duration_minutes = Math.floor(duration_secs / 60);
const minutes = duration_minutes % 60;
const duration_hours = Math.floor(duration_minutes / 60);
const millis_str = millis.toLocaleString('en-US', {
minimumIntegerDigits: 3,
useGrouping: false,
maximumFractionDigits: 0,
});
const seconds_str = seconds.toLocaleString('en-US', {
minimumIntegerDigits: 2,
useGrouping: false,
maximumFractionDigits: 0,
});
const minutes_str = minutes.toLocaleString('en-US', {
minimumIntegerDigits: 2,
useGrouping: false,
maximumFractionDigits: 0,
});
const hours_str = duration_hours.toLocaleString('en-US', {
minimumIntegerDigits: 1,
useGrouping: false,
maximumFractionDigits: 0,
});
return `${hours_str}:${minutes_str}:${seconds_str}.${millis_str}`;
}
export function parseDurationString(duration: string): number {
const parts = duration.split(':');
const seconds_str = parts.length >= 1 ? parts[parts.length - 1] : '0';
const minutes_str = parts.length >= 2 ? parts[parts.length - 2] : '0';
const hours_str = parts.length >= 3 ? parts[parts.length - 3] : '0';
const seconds = Number.parseFloat(seconds_str);
const minutes = Number.parseFloat(minutes_str);
const hours = Number.parseFloat(hours_str);
return Math.floor(((hours * 60 + minutes) * 60 + seconds) * 1000);
}

View File

@@ -12,6 +12,7 @@ export interface GraphData {
border_top: MaybeRefOrGetter<number>; border_top: MaybeRefOrGetter<number>;
min_x: MaybeRefOrGetter<number>; min_x: MaybeRefOrGetter<number>;
max_x: MaybeRefOrGetter<number>; max_x: MaybeRefOrGetter<number>;
max_temporal_resolution: MaybeRefOrGetter<number>;
live: MaybeRefOrGetter<boolean>; live: MaybeRefOrGetter<boolean>;
fetch_history: MaybeRefOrGetter<number>; fetch_history: MaybeRefOrGetter<number>;
width: MaybeRefOrGetter<number>; width: MaybeRefOrGetter<number>;

View File

@@ -1,4 +1,5 @@
import './assets/main.scss'; import './assets/main.scss';
import './assets/layout.scss';
import { createApp } from 'vue'; import { createApp } from 'vue';
import App from './App.vue'; import App from './App.vue';

View File

@@ -0,0 +1,104 @@
import type { RouteLocationRaw } from 'vue-router';
export enum PanelHeirarchyType {
LEAF = 'leaf',
FOLDER = 'folder',
}
export type PanelHeirarchyLeaf = {
name: string;
to: RouteLocationRaw;
type: PanelHeirarchyType.LEAF;
};
export type PanelHeirarchyFolder = {
name: string;
children: PanelHeirarchyChildren;
type: PanelHeirarchyType.FOLDER;
};
export type PanelHeirarchyChildren = (
| PanelHeirarchyFolder
| PanelHeirarchyLeaf
)[];
export function getPanelHeirarchy(): PanelHeirarchyChildren {
const result: PanelHeirarchyChildren = [];
result.push({
name: 'Graph Test',
to: { name: 'graph' },
type: PanelHeirarchyType.LEAF,
});
result.push({
name: 'Telemetry Elements',
to: { name: 'list' },
type: PanelHeirarchyType.LEAF,
});
result.push({
name: 'Chart',
to: { name: 'chart' },
type: PanelHeirarchyType.LEAF,
});
result.push({
name: 'Panel Test',
to: { name: 'panel_test' },
type: PanelHeirarchyType.LEAF,
});
return result;
}
export function filterHeirarchy(
heirarchy: PanelHeirarchyChildren,
predicate: (leaf: PanelHeirarchyLeaf) => boolean,
): PanelHeirarchyChildren {
const result: PanelHeirarchyChildren = [];
for (const element of heirarchy) {
switch (element.type) {
case PanelHeirarchyType.LEAF:
if (predicate(element)) {
result.push(element);
}
break;
case PanelHeirarchyType.FOLDER:
const folder_contents = filterHeirarchy(
element.children,
predicate,
);
if (folder_contents.length > 0) {
result.push({
name: element.name,
children: folder_contents,
type: PanelHeirarchyType.FOLDER,
});
}
break;
}
}
return result;
}
export function getFirstLeaf(
heirarchy: PanelHeirarchyChildren,
): PanelHeirarchyLeaf | null {
for (const element of heirarchy) {
switch (element.type) {
case PanelHeirarchyType.LEAF:
return element;
case PanelHeirarchyType.FOLDER:
const leaf = getFirstLeaf(element.children);
if (leaf != null) {
return leaf;
}
break;
}
}
return null;
}

View File

@@ -6,7 +6,27 @@ const router = createRouter({
{ {
path: '/', path: '/',
name: 'home', name: 'home',
component: () => import('../views/HomeView.vue'), component: () => import('../views/EmptyPanelView.vue'),
},
{
path: '/graph',
name: 'graph',
component: () => import('../views/GraphView.vue'),
},
{
path: '/list',
name: 'list',
component: () => import('../views/TelemetryListView.vue'),
},
{
path: '/chart',
name: 'chart',
component: () => import('../views/ChartView.vue'),
},
{
path: '/panel_test',
name: 'panel_test',
component: () => import('../views/PanelTest.vue'),
}, },
], ],
}); });

View File

@@ -0,0 +1,309 @@
<script setup lang="ts">
import FlexDivider from '@/components/FlexDivider.vue';
import TelemetryList from '@/components/TelemetryList.vue';
import { onMounted, ref } from 'vue';
import TextInput from '@/components/TextInput.vue';
import type { TelemetryDefinition } from '@/composables/telemetry.ts';
import InlineIcon from '@/components/InlineIcon.vue';
const model = defineModel<TelemetryDefinition[][][]>({
required: true,
default: [],
});
const selected = ref(0);
const selected_cell_x = ref(0);
const selected_cell_y = ref(0);
const searchValue = ref('');
const options = [
[1, 1],
[2, 1],
[1, 2],
[2, 2],
[3, 2],
];
function selectOption(i: number) {
selected.value = i;
if (selected_cell_x.value >= options[i][0]) {
selected_cell_x.value = 0;
}
if (selected_cell_y.value >= options[i][1]) {
selected_cell_y.value = 0;
}
const initial_cells = model.value;
const result_cells: TelemetryDefinition[][][] = [];
for (let x = 0; x < options[i][0]; x++) {
result_cells.push([]);
for (let y = 0; y < options[i][1]; y++) {
if (x < initial_cells.length && y < initial_cells[x].length) {
result_cells[x].push(initial_cells[x][y]);
} else {
result_cells[x].push([]);
}
}
}
model.value = result_cells;
}
onMounted(() => {
const model_value = model.value;
let s = 0;
for (let i = 0; i < options.length; i++) {
// X length correct
if (options[i][0] == model_value.length) {
// Y length correct
if (options[i][1] == model_value[0].length) {
// selectOption(i);
s = i;
break;
}
}
}
// Fall back to option 0
selectOption(s);
});
function selectCell(x: number, y: number) {
selected_cell_x.value = x;
selected_cell_y.value = y;
}
function selectTelemetry(telemetry: TelemetryDefinition | null) {
if (telemetry != null) {
if (
!model.value[selected_cell_x.value][selected_cell_y.value].includes(
telemetry,
)
) {
model.value[selected_cell_x.value][selected_cell_y.value].push(
telemetry,
);
}
}
}
function moveUp(index: number) {
if (index > 0) {
const removed = model.value[selected_cell_x.value][
selected_cell_y.value
].splice(index - 1, 2);
removed.reverse();
model.value[selected_cell_x.value][selected_cell_y.value].splice(
index - 1,
0,
...removed,
);
}
}
function moveDown(index: number) {
if (
index + 1 <
model.value[selected_cell_x.value][selected_cell_y.value].length
) {
const removed = model.value[selected_cell_x.value][
selected_cell_y.value
].splice(index, 2);
removed.reverse();
model.value[selected_cell_x.value][selected_cell_y.value].splice(
index,
0,
...removed,
);
}
}
function removeTelemetry(index: number) {
model.value[selected_cell_x.value][selected_cell_y.value].splice(index, 1);
}
</script>
<template>
<div class="row grow stretch">
<div class="column grow stretch no-basis">
<div class="grow no-basis type_grid no-min-height scroll">
<svg
v-for="(option, i) in options"
:key="i"
:class="`layout ${selected == i ? 'selected' : ''}`"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
@click="selectOption(i)"
>
<template v-for="x in option[0]" :key="x">
<template v-for="y in option[1]" :key="y">
<rect
:x="1 + ((x - 1) * 98) / option[0]"
:y="1 + ((y - 1) * 98) / option[1]"
:width="98 / option[0]"
:height="98 / option[1]"
></rect>
</template>
</template>
</svg>
</div>
<FlexDivider class="horizontal_divider_margin"></FlexDivider>
<div class="row grow center no-basis">
<div
class="selected_grid grow"
:style="`grid-template: repeat(${options[selected][1]}, 1fr) / repeat(${options[selected][0]}, 1fr);`"
>
<template v-for="y in options[selected][1]" :key="y">
<template v-for="x in options[selected][0]" :key="x">
<div
:class="`cell ${selected_cell_x == x - 1 && selected_cell_y == y - 1 ? 'selected' : ''}`"
@click="selectCell(x - 1, y - 1)"
></div>
</template>
</template>
</div>
</div>
</div>
<FlexDivider></FlexDivider>
<div class="column grow2 stretch no-basis">
<div class="row grow stretch no-basis">
<div class="column grow stretch">
<template v-if="model.length > 0">
<div
class="row chosen"
v-for="(selected, i) in model[selected_cell_x][
selected_cell_y
]"
:key="i"
>
<span>
{{ selected.name }}
</span>
<span class="grow"></span>
<div class="column tiny_text">
<InlineIcon
icon="up arrow"
:class="`${i == 0 ? 'hidden' : 'button'}`"
@click="moveUp(i)"
></InlineIcon>
<InlineIcon
icon="down arrow"
:class="`${i == model[selected_cell_x][selected_cell_y].length - 1 ? 'hidden' : 'button'}`"
@click="moveDown(i)"
></InlineIcon>
</div>
<span
class="close icon button"
@click="removeTelemetry(i)"
>
<InlineIcon icon="close"></InlineIcon>
</span>
</div>
</template>
</div>
</div>
<FlexDivider class="horizontal_divider_margin"></FlexDivider>
<div class="row grow stretch no-basis">
<div class="column grow stretch no-basis">
<div class="row">
<TextInput
autofocus
class="grow"
v-model="searchValue"
placeholder="Search"
></TextInput>
</div>
<div class="row scroll grow no-min-height no-basis">
<div class="column grow stretch">
<TelemetryList
:search="searchValue"
:model-value="null"
@update:model-value="
(value) => selectTelemetry(value)
"
></TelemetryList>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/assets/variables';
.type_grid {
display: grid;
grid-auto-flow: row;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
grid-auto-rows: max-content;
gap: 1em;
justify-items: stretch;
align-items: stretch;
justify-content: center;
align-content: center;
}
.type_grid > * {
aspect-ratio: 1 / 1;
}
.layout {
stroke: variables.$grid-line;
stroke-width: 1px;
fill: none;
cursor: pointer;
}
.selected {
background-color: variables.$light-background-color;
}
.selected_grid {
display: grid;
gap: 1em;
justify-items: stretch;
align-self: stretch;
justify-content: stretch;
align-content: stretch;
}
.cell {
border: variables.$grid-line 1px solid;
cursor: pointer;
}
.horizontal_divider_margin {
margin: 1em 0;
}
.chosen {
padding: 0.3em;
border: 0;
border-bottom: variables.$gray-3 solid 1px;
border-top: 0;
align-items: center;
}
.chosen:first-child {
border-top: variables.$gray-3 solid 1px;
}
.chosen:hover {
background-color: variables.$light2-background-color;
}
.close.icon.button {
height: 1em;
cursor: pointer;
}
.arrow.icon.button {
cursor: pointer;
}
.column.tiny_text {
font-size: calc(variables.$normal-text-size / 2);
height: 100%;
justify-content: space-between;
}
</style>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import type { TelemetryDefinition } from '@/composables/telemetry.ts';
import SvgGraph from '@/components/SvgGraph.vue';
import { GraphSide } from '@/graph/graph.ts';
import GraphAxis from '@/components/GraphAxis.vue';
import TelemetryLine from '@/components/TelemetryLine.vue';
defineProps<{
charts: TelemetryDefinition[][][];
}>();
</script>
<template>
<div
class="chart grid grow"
:style="`grid-template: repeat(${charts[0].length} , 1fr) / repeat(${charts.length}, 1fr);`"
v-if="charts.length > 0"
>
<template v-for="y in charts[0].length" :key="y">
<template v-for="x in charts.length" :key="x">
<SvgGraph
right_axis
cursor
:legend="GraphSide.Left"
include_controls
>
<GraphAxis>
<TelemetryLine
v-for="tlm in charts[x - 1][y - 1]"
:key="tlm.uuid"
:data="tlm.name"
></TelemetryLine>
</GraphAxis>
</SvgGraph>
</template>
</template>
</div>
</template>
<style scoped lang="scss">
@use '@/assets/variables';
.chart.grid {
display: grid;
grid-auto-flow: row;
place-content: stretch;
height: 100%;
}
</style>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import { ref } from 'vue';
import InlineIcon from '@/components/InlineIcon.vue';
import ChartDefinitionView from '@/views/ChartDefinitionView.vue';
import type { TelemetryDefinition } from '@/composables/telemetry.ts';
import ChartRenderView from '@/views/ChartRenderView.vue';
import ScreenLayout from '@/components/layout/ScreenLayout.vue';
import { Direction } from '@/composables/Direction.ts';
import { ScreenType } from '@/composables/ScreenType.ts';
const settingsOpen = ref(true);
const settings = ref<TelemetryDefinition[][][]>([]);
function openSettings() {
settingsOpen.value = true;
}
function closeSettings() {
settingsOpen.value = false;
}
</script>
<template>
<ScreenLayout :direction="Direction.Column" :type="ScreenType.WideTall">
<template v-if="!settingsOpen">
<div class="settings column" @click="openSettings">
<InlineIcon icon="hamburger menu"></InlineIcon>
</div>
<ChartRenderView :charts="settings"></ChartRenderView>
</template>
<template v-else>
<div class="settings column" @click="closeSettings">
<InlineIcon icon="close"></InlineIcon>
</div>
<ChartDefinitionView v-model="settings"></ChartDefinitionView>
</template>
</ScreenLayout>
</template>
<style scoped lang="scss">
@use '@/assets/variables';
.settings {
position: absolute;
top: 0;
right: 0;
background-color: variables.$dark-background-color;
border: 1px solid variables.$light2-background-color;
padding: 0.5em;
margin: 1px;
aspect-ratio: 1 / 1;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import {
filterHeirarchy,
getFirstLeaf,
getPanelHeirarchy,
} from '@/panels/panel';
import PanelHeirarchy from '@/components/PanelHeirarchy.vue';
import router from '@/router';
import TextInput from '@/components/TextInput.vue';
import ScreenLayout from '@/components/layout/ScreenLayout.vue';
import { Direction } from '@/composables/Direction.ts';
import { ScreenType } from '@/composables/ScreenType.ts';
const searchValue = ref('');
const heirarchy = getPanelHeirarchy();
const filtered_heirarchy = computed(() =>
filterHeirarchy(heirarchy, (leaf) => {
return leaf.name
.toLowerCase()
.includes(searchValue.value.toLowerCase());
}),
);
function onEnter() {
const leaf = getFirstLeaf(filtered_heirarchy.value);
if (leaf != null) {
router.push(leaf.to);
}
}
</script>
<template>
<ScreenLayout :direction="Direction.Column" :type="ScreenType.Page" limit>
<div class="row">
<TextInput
autofocus
class="grow"
v-model="searchValue"
placeholder="Search"
@enter="onEnter"
></TextInput>
</div>
<div class="row grow stretch no-min-height">
<div class="column grow scroll no-min-height">
<PanelHeirarchy
:heirarchy="filtered_heirarchy"
v-if="filtered_heirarchy.length > 0"
></PanelHeirarchy>
<div v-else>
<span>No Matches Found</span>
</div>
</div>
</div>
</ScreenLayout>
</template>
<style scoped lang="scss">
@use '@/assets/variables';
</style>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import Axis from '@/components/GraphAxis.vue';
import Line from '@/components/TelemetryLine.vue';
import { GraphSide } from '@/graph/graph';
import SvgGraph from '@/components/SvgGraph.vue';
</script>
<template>
<div class="column">
<div style="width: 100vw; height: 50vh">
<SvgGraph
:legend="GraphSide.Left"
right_axis
include_controls
cursor
>
<Axis>
<Line data="simple_producer/time_offset"></Line>
<Line data="simple_producer/publish_offset"></Line>
<Line data="simple_producer/await_offset"></Line>
</Axis>
</SvgGraph>
</div>
<div style="width: 100vw; height: 50vh">
<SvgGraph
:initial_duration="60 * 1000 * 10"
:legend="GraphSide.Right"
right_axis
>
<Axis>
<Line data="simple_producer/sin"></Line>
<Line data="simple_producer/cos4"></Line>
<Line data="simple_producer/sin2"></Line>
<Line data="simple_producer/cos"></Line>
<Line data="simple_producer/sin3"></Line>
<Line data="simple_producer/cos2"></Line>
<Line data="simple_producer/sin4"></Line>
<Line data="simple_producer/cos3"></Line>
</Axis>
</SvgGraph>
</div>
</div>
</template>
<style lang="scss">
@use '@/assets/variables';
</style>

View File

@@ -1,69 +0,0 @@
<script setup lang="ts">
import { useWebsocket, WEBSOCKET_SYMBOL } from '@/composables/websocket';
import { provide } from 'vue';
import Axis from '@/components/GraphAxis.vue';
import Line from '@/components/TelemetryLine.vue';
import { GraphSide } from '@/graph/graph';
import SvgGraph from '@/components/SvgGraph.vue';
const websocket = useWebsocket();
provide(WEBSOCKET_SYMBOL, websocket);
</script>
<template>
<div style="width: 100vw; height: 50vh">
<SvgGraph :legend="GraphSide.Left" right_axis include_controls cursor>
<Axis>
<Line data="simple_producer/time_offset"></Line>
<Line data="simple_producer/publish_offset"></Line>
<Line data="simple_producer/await_offset"></Line>
</Axis>
</SvgGraph>
</div>
<div style="width: 100vw; height: 50vh">
<SvgGraph
:duration="60 * 1000 * 10"
:legend="GraphSide.Right"
right_axis
>
<Axis>
<Line
data="simple_producer/sin"
:minimum_separation="1000"
></Line>
<Line
data="simple_producer/cos4"
:minimum_separation="1000"
></Line>
<Line
data="simple_producer/sin2"
:minimum_separation="1000"
></Line>
<Line
data="simple_producer/cos"
:minimum_separation="1000"
></Line>
<Line
data="simple_producer/sin3"
:minimum_separation="1000"
></Line>
<Line
data="simple_producer/cos2"
:minimum_separation="1000"
></Line>
<Line
data="simple_producer/sin4"
:minimum_separation="1000"
></Line>
<Line
data="simple_producer/cos3"
:minimum_separation="1000"
></Line>
</Axis>
</SvgGraph>
</div>
</template>
<style lang="scss">
@use '@/assets/variables';
</style>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import GridLayout from '@/components/layout/GridLayout.vue';
import TelemetryValue from '@/components/TelemetryValue.vue';
</script>
<template>
<GridLayout :cols="2" equal_col_width>
<span class="justify-right"> simple_producer/cos </span>
<TelemetryValue data="simple_producer/cos"></TelemetryValue>
<span class="justify-right"> simple_producer/sin </span>
<TelemetryValue data="simple_producer/sin"></TelemetryValue>
<span class="justify-right"> simple_producer/cos2 </span>
<TelemetryValue data="simple_producer/cos2"></TelemetryValue>
<span class="justify-right"> simple_producer/sin2 </span>
<TelemetryValue data="simple_producer/sin2"></TelemetryValue>
</GridLayout>
</template>
<style scoped lang="scss">
@use '@/assets/variables';
.justify-right {
justify-self: end;
}
</style>

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import TextInput from '@/components/TextInput.vue';
import { ref } from 'vue';
import TelemetryList from '@/components/TelemetryList.vue';
import type { TelemetryDefinition } from '@/composables/telemetry';
import TelemetryInfo from '@/components/TelemetryInfo.vue';
import FlexDivider from '@/components/FlexDivider.vue';
import ScreenLayout from '@/components/layout/ScreenLayout.vue';
import { Direction } from '@/composables/Direction.ts';
import { ScreenType } from '@/composables/ScreenType.ts';
const searchValue = ref('');
const selected = ref<TelemetryDefinition | null>(null);
const mousedover = ref<TelemetryDefinition | null>(null);
</script>
<template>
<ScreenLayout :direction="Direction.Row" :type="ScreenType.Standard" limit>
<div class="column grow2 stretch no-min-height no-basis">
<div class="row">
<TextInput
autofocus
class="grow"
v-model="searchValue"
placeholder="Search"
></TextInput>
</div>
<div class="row scroll no-min-height">
<div class="column grow stretch">
<TelemetryList
:search="searchValue"
v-model="selected"
@mouseover="
(mousedover_value) =>
(mousedover = mousedover_value)
"
></TelemetryList>
</div>
</div>
</div>
<FlexDivider></FlexDivider>
<div class="column grow stretch no-basis">
<TelemetryInfo
:mouseover="mousedover"
:selection="selected"
></TelemetryInfo>
</div>
</ScreenLayout>
</template>
<style lang="scss">
@use '@/assets/variables';
</style>

View File

@@ -7,21 +7,21 @@ authors = ["Sergey <me@sergeysav.com>"]
[dependencies] [dependencies]
fern = "0.7.1" fern = "0.7.1"
log = "0.4.22" log = "0.4.25"
prost = "0.13.4" prost = "0.13.5"
rand = "0.8.5" rand = "0.9.0"
tonic = { version = "0.12.3" } tonic = { version = "0.12.3" }
tokio = { version = "1.42.0", features = ["rt-multi-thread", "signal", "fs"] } tokio = { version = "1.43.0", features = ["rt-multi-thread", "signal", "fs"] }
chrono = "0.4.39" chrono = "0.4.39"
actix-web = "4.9.0" actix-web = "4.9.0"
actix-ws = "0.3.0" actix-ws = "0.3.0"
tokio-util = "0.7.13" tokio-util = "0.7.13"
serde = { version = "1.0.217", features = ["derive"] } serde = { version = "1.0.217", features = ["derive"] }
serde_json = "1.0.134" serde_json = "1.0.138"
hex = "0.4.3" hex = "0.4.3"
papaya = "0.1.7" papaya = "0.1.8"
thiserror = "2.0.9" thiserror = "2.0.11"
derive_more = { version = "1.0.0", features = ["from"] } derive_more = { version = "2.0.1", features = ["from"] }
anyhow = "1.0.95" anyhow = "1.0.95"
[build-dependencies] [build-dependencies]

View File

@@ -21,6 +21,14 @@ async fn get_tlm_definition(
Ok(web::Json(data.definition.clone())) Ok(web::Json(data.definition.clone()))
} }
#[get("/tlm/info")]
async fn get_all_tlm_definitions(
data: web::Data<Arc<TelemetryManagementService>>,
) -> Result<impl Responder, HttpServerResultError> {
trace!("get_all_tlm_definitions");
Ok(web::Json(data.get_all_definitions()))
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct HistoryQuery { struct HistoryQuery {
from: String, from: String,
@@ -71,5 +79,7 @@ async fn get_tlm_history(
} }
pub fn setup_api(cfg: &mut web::ServiceConfig) { pub fn setup_api(cfg: &mut web::ServiceConfig) {
cfg.service(get_tlm_definition).service(get_tlm_history); cfg.service(get_all_tlm_definitions)
.service(get_tlm_definition)
.service(get_tlm_history);
} }

View File

@@ -1,16 +1,18 @@
use std::collections::HashMap; use crate::http::websocket::request::{
use crate::http::websocket::request::{RegisterTlmListenerRequest, UnregisterTlmListenerRequest, WebsocketRequest}; RegisterTlmListenerRequest, UnregisterTlmListenerRequest, WebsocketRequest,
};
use crate::http::websocket::response::{TlmValueResponse, WebsocketResponse}; use crate::http::websocket::response::{TlmValueResponse, WebsocketResponse};
use crate::telemetry::management_service::TelemetryManagementService; use crate::telemetry::management_service::TelemetryManagementService;
use actix_web::{rt, web, HttpRequest, HttpResponse}; use actix_web::{rt, web, HttpRequest, HttpResponse};
use actix_ws::{AggregatedMessage, ProtocolError, Session}; use actix_ws::{AggregatedMessage, ProtocolError, Session};
use anyhow::anyhow; use anyhow::anyhow;
use log::{error, trace}; use log::{error, trace};
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::select;
use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::Sender;
use tokio::time::{sleep, Instant}; use tokio::time::{sleep_until, Instant};
use tokio::{pin, select};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tonic::codegen::tokio_stream::StreamExt; use tonic::codegen::tokio_stream::StreamExt;
@@ -32,48 +34,29 @@ fn handle_register_tlm_listener(
let mut rx = tlm_data.data.subscribe(); let mut rx = tlm_data.data.subscribe();
let tx = tx.clone(); let tx = tx.clone();
rt::spawn(async move { rt::spawn(async move {
let mut last_sent_at = Instant::now() - minimum_separation;
let mut last_value = None;
let sleep = sleep(Duration::from_millis(0));
pin!(sleep);
loop { loop {
select! { let now = select! {
_ = tx.closed() => { biased;
break; _ = tx.closed() => { break; }
} _ = token.cancelled() => { break; }
_ = token.cancelled() => {
break;
}
Ok(_) = rx.changed() => { Ok(_) = rx.changed() => {
let now = Instant::now(); let now = Instant::now();
let value = { let value = {
let ref_val = rx.borrow_and_update(); let ref_val = rx.borrow_and_update();
ref_val.clone() ref_val.clone()
}; };
if last_sent_at + minimum_separation > now {
last_value = value;
sleep.as_mut().reset(last_sent_at + minimum_separation);
continue;
} else {
last_value = None;
last_sent_at = now;
}
let _ = tx.send(TlmValueResponse { let _ = tx.send(TlmValueResponse {
uuid: request.uuid.clone(), uuid: request.uuid.clone(),
value, value,
}.into()).await; }.into()).await;
now
} }
() = &mut sleep => { };
if let Some(value) = last_value { select! {
let _ = tx.send(TlmValueResponse { biased;
uuid: request.uuid.clone(), _ = tx.closed() => { break; }
value: Some(value), _ = token.cancelled() => { break; }
}.into()).await; _ = sleep_until(now + minimum_separation) => {}
}
last_value = None;
let now = Instant::now();
last_sent_at = now;
}
} }
} }
}); });
@@ -98,10 +81,10 @@ async fn handle_websocket_message(
match request { match request {
WebsocketRequest::RegisterTlmListener(request) => { WebsocketRequest::RegisterTlmListener(request) => {
handle_register_tlm_listener(data, request, tx, tlm_listeners) handle_register_tlm_listener(data, request, tx, tlm_listeners)
}, }
WebsocketRequest::UnregisterTlmListener(request) => { WebsocketRequest::UnregisterTlmListener(request) => {
handle_unregister_tlm_listener(request, tlm_listeners) handle_unregister_tlm_listener(request, tlm_listeners)
}, }
}; };
} }

View File

@@ -1,23 +1,29 @@
use std::fs::File;
use std::io; use std::io;
use std::io::{Read, Write};
pub trait FileWriteableType { pub trait FileWriteableType {
fn write_to_file(self, file: &mut File) -> io::Result<()>; fn write_to_file(self, file: &mut impl Write) -> io::Result<()>;
} }
pub trait FileReadableType: Sized { pub trait FileReadableType: Sized {
fn read_from_file(file: &mut File) -> io::Result<Self>; fn read_from_file(file: &mut impl Read) -> io::Result<Self>;
} }
pub trait FileExt { pub trait WriteExt {
fn write_data<T: FileWriteableType>(&mut self, data: T) -> io::Result<()>; fn write_data<T: FileWriteableType>(&mut self, data: T) -> io::Result<()>;
}
pub trait ReadExt {
fn read_data<T: FileReadableType>(&mut self) -> io::Result<T>; fn read_data<T: FileReadableType>(&mut self) -> io::Result<T>;
} }
impl FileExt for File { impl<W: Write> WriteExt for W {
fn write_data<T: FileWriteableType>(&mut self, data: T) -> io::Result<()> { fn write_data<T: FileWriteableType>(&mut self, data: T) -> io::Result<()> {
data.write_to_file(self) data.write_to_file(self)
} }
}
impl<R: Read> ReadExt for R {
fn read_data<T: FileReadableType>(&mut self) -> io::Result<T> { fn read_data<T: FileReadableType>(&mut self) -> io::Result<T> {
T::read_from_file(self) T::read_from_file(self)
} }

View File

@@ -1,16 +1,15 @@
use crate::serialization::file_ext::{FileReadableType, FileWriteableType}; use crate::serialization::file_ext::{FileReadableType, FileWriteableType};
use std::fs::File;
use std::io::{Read, Write}; use std::io::{Read, Write};
macro_rules! primitive_write_read { macro_rules! primitive_write_read {
( $primitive:ty, $length:expr ) => { ( $primitive:ty, $length:expr ) => {
impl FileWriteableType for $primitive { impl FileWriteableType for $primitive {
fn write_to_file(self, file: &mut File) -> std::io::Result<()> { fn write_to_file(self, file: &mut impl Write) -> std::io::Result<()> {
file.write_all(&self.to_be_bytes()) file.write_all(&self.to_be_bytes())
} }
} }
impl FileReadableType for $primitive { impl FileReadableType for $primitive {
fn read_from_file(file: &mut File) -> std::io::Result<Self> { fn read_from_file(file: &mut impl Read) -> std::io::Result<Self> {
let mut buffer = [0u8; $length]; let mut buffer = [0u8; $length];
file.read_exact(&mut buffer)?; file.read_exact(&mut buffer)?;
Ok(Self::from_be_bytes(buffer)) Ok(Self::from_be_bytes(buffer))

View File

@@ -1,5 +1,5 @@
use crate::core::TelemetryDataType; use crate::core::TelemetryDataType;
use crate::serialization::file_ext::FileExt; use crate::serialization::file_ext::{ReadExt, WriteExt};
use crate::telemetry::data::TelemetryData; use crate::telemetry::data::TelemetryData;
use crate::telemetry::data_item::TelemetryDataItem; use crate::telemetry::data_item::TelemetryDataItem;
use crate::telemetry::data_value::TelemetryDataValue; use crate::telemetry::data_value::TelemetryDataValue;
@@ -10,7 +10,7 @@ use log::{error, info};
use std::cmp::min; use std::cmp::min;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::fs::File; use std::fs::File;
use std::io::{Seek, SeekFrom, Write}; use std::io::{BufReader, BufWriter, Seek, SeekFrom, Write};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use std::{fs, path}; use std::{fs, path};
@@ -137,7 +137,8 @@ struct HistorySegmentFile {
start: DateTime<Utc>, start: DateTime<Utc>,
end: DateTime<Utc>, end: DateTime<Utc>,
length: u64, length: u64,
file: File, file: BufReader<File>,
file_position: i64,
} }
impl HistorySegmentFile { impl HistorySegmentFile {
@@ -158,31 +159,16 @@ impl HistorySegmentFile {
segment.start.to_rfc3339_opts(SecondsFormat::Secs, true) segment.start.to_rfc3339_opts(SecondsFormat::Secs, true)
)); ));
let file = File::create(file)?; let mut file = BufWriter::new(File::create(file)?);
let mut result = Self { let utc_offset_start = segment.start - DateTime::UNIX_EPOCH;
start: segment.start, let utc_offset_end = segment.end - DateTime::UNIX_EPOCH;
end: segment.end,
length: 0,
file,
};
let utc_offset_start = result.start - DateTime::UNIX_EPOCH;
let utc_offset_end = result.end - DateTime::UNIX_EPOCH;
// Write the segment bounds // Write the segment bounds
result file.write_data::<i64>(utc_offset_start.num_seconds())?;
.file file.write_data::<i32>(utc_offset_start.subsec_nanos())?;
.write_data::<i64>(utc_offset_start.num_seconds())?; file.write_data::<i64>(utc_offset_end.num_seconds())?;
result file.write_data::<i32>(utc_offset_end.subsec_nanos())?;
.file
.write_data::<i32>(utc_offset_start.subsec_nanos())?;
result
.file
.write_data::<i64>(utc_offset_end.num_seconds())?;
result
.file
.write_data::<i32>(utc_offset_end.subsec_nanos())?;
let data = segment.data.get_mut().unwrap_or_else(|err| { let data = segment.data.get_mut().unwrap_or_else(|err| {
error!( error!(
@@ -197,26 +183,35 @@ impl HistorySegmentFile {
"Invalid Segment Cannot Be Saved to Disk" "Invalid Segment Cannot Be Saved to Disk"
); );
result.length = data.timestamps.len() as u64; let length = data.timestamps.len() as u64;
result.file.write_data::<u64>(result.length)?; file.write_data::<u64>(length)?;
// Write all the timestamps // Write all the timestamps
for timestamp in &data.timestamps { for timestamp in &data.timestamps {
let utc_offset = *timestamp - DateTime::UNIX_EPOCH; let utc_offset = *timestamp - DateTime::UNIX_EPOCH;
result.file.write_data::<i64>(utc_offset.num_seconds())?; file.write_data::<i64>(utc_offset.num_seconds())?;
result.file.write_data::<i32>(utc_offset.subsec_nanos())?; file.write_data::<i32>(utc_offset.subsec_nanos())?;
} }
// Write all the values // Write all the values
for value in &data.values { for value in &data.values {
match value { match value {
TelemetryDataValue::Float32(value) => result.file.write_data::<f32>(*value)?, TelemetryDataValue::Float32(value) => file.write_data::<f32>(*value)?,
TelemetryDataValue::Float64(value) => result.file.write_data::<f64>(*value)?, TelemetryDataValue::Float64(value) => file.write_data::<f64>(*value)?,
} }
} }
result.file.flush()?; file.flush()?;
Ok(result)
let mut file = BufReader::new(file.into_inner()?);
file.seek(SeekFrom::Start(0))?;
Ok(Self {
start: segment.start,
end: segment.end,
length: 0,
file,
file_position: 0,
})
} }
fn load_to_ram( fn load_to_ram(
@@ -229,6 +224,7 @@ impl HistorySegmentFile {
}; };
self.file.seek(SeekFrom::Start(Self::HEADER_LENGTH))?; self.file.seek(SeekFrom::Start(Self::HEADER_LENGTH))?;
self.file_position = Self::HEADER_LENGTH as i64;
for _ in 0..self.length { for _ in 0..self.length {
segment_data.timestamps.push(self.read_date_time()?); segment_data.timestamps.push(self.read_date_time()?);
} }
@@ -255,7 +251,7 @@ impl HistorySegmentFile {
start.to_rfc3339_opts(SecondsFormat::Secs, true) start.to_rfc3339_opts(SecondsFormat::Secs, true)
)); ));
let mut file = File::open(file)?; let mut file = BufReader::new(File::open(file)?);
// Write the segment bounds // Write the segment bounds
let start_seconds = file.read_data::<i64>()?; let start_seconds = file.read_data::<i64>()?;
@@ -269,11 +265,13 @@ impl HistorySegmentFile {
let length = file.read_data::<u64>()?; let length = file.read_data::<u64>()?;
file.seek(SeekFrom::Start(0))?;
Ok(HistorySegmentFile { Ok(HistorySegmentFile {
start: DateTime::UNIX_EPOCH + start, start: DateTime::UNIX_EPOCH + start,
end: DateTime::UNIX_EPOCH + end, end: DateTime::UNIX_EPOCH + end,
length, length,
file, file,
file_position: 0,
}) })
} }
@@ -284,6 +282,8 @@ impl HistorySegmentFile {
maximum_resolution: TimeDelta, maximum_resolution: TimeDelta,
telemetry_data_type: TelemetryDataType, telemetry_data_type: TelemetryDataType,
) -> anyhow::Result<(DateTime<Utc>, Vec<TelemetryDataItem>)> { ) -> anyhow::Result<(DateTime<Utc>, Vec<TelemetryDataItem>)> {
self.file_position = 0;
self.file.seek(SeekFrom::Start(0))?;
let mut result = vec![]; let mut result = vec![];
let mut next_from = from; let mut next_from = from;
@@ -319,15 +319,17 @@ impl HistorySegmentFile {
fn read_date_time(&mut self) -> anyhow::Result<DateTime<Utc>> { fn read_date_time(&mut self) -> anyhow::Result<DateTime<Utc>> {
let seconds = self.file.read_data::<i64>()?; let seconds = self.file.read_data::<i64>()?;
let nanos = self.file.read_data::<i32>()?; let nanos = self.file.read_data::<i32>()?;
self.file_position += 8 + 4;
let start = let start =
TimeDelta::new(seconds, nanos as u32).context("Failed to reconstruct TimeDelta")?; TimeDelta::new(seconds, nanos as u32).context("Failed to reconstruct TimeDelta")?;
Ok(DateTime::UNIX_EPOCH + start) Ok(DateTime::UNIX_EPOCH + start)
} }
fn get_date_time(&mut self, index: u64) -> anyhow::Result<DateTime<Utc>> { fn get_date_time(&mut self, index: u64) -> anyhow::Result<DateTime<Utc>> {
self.file.seek(SeekFrom::Start( let desired_position = Self::HEADER_LENGTH + index * Self::TIMESTAMP_LENGTH;
Self::HEADER_LENGTH + index * Self::TIMESTAMP_LENGTH, let seek_amount = desired_position as i64 - self.file_position;
))?; self.file_position += seek_amount;
self.file.seek_relative(seek_amount)?;
self.read_date_time() self.read_date_time()
} }
@@ -337,9 +339,11 @@ impl HistorySegmentFile {
) -> anyhow::Result<TelemetryDataValue> { ) -> anyhow::Result<TelemetryDataValue> {
match telemetry_data_type { match telemetry_data_type {
TelemetryDataType::Float32 => { TelemetryDataType::Float32 => {
self.file_position += 4;
Ok(TelemetryDataValue::Float32(self.file.read_data::<f32>()?)) Ok(TelemetryDataValue::Float32(self.file.read_data::<f32>()?))
} }
TelemetryDataType::Float64 => { TelemetryDataType::Float64 => {
self.file_position += 8;
Ok(TelemetryDataValue::Float64(self.file.read_data::<f64>()?)) Ok(TelemetryDataValue::Float64(self.file.read_data::<f64>()?))
} }
} }
@@ -354,9 +358,11 @@ impl HistorySegmentFile {
TelemetryDataType::Float32 => 4, TelemetryDataType::Float32 => 4,
TelemetryDataType::Float64 => 8, TelemetryDataType::Float64 => 8,
}; };
self.file.seek(SeekFrom::Start( let desired_position =
Self::HEADER_LENGTH + self.length * Self::TIMESTAMP_LENGTH + index * item_length, Self::HEADER_LENGTH + self.length * Self::TIMESTAMP_LENGTH + index * item_length;
))?; let seek_amount = desired_position as i64 - self.file_position;
self.file_position += seek_amount;
self.file.seek_relative(seek_amount)?;
self.read_telemetry_item(telemetry_data_type) self.read_telemetry_item(telemetry_data_type)
} }
@@ -534,14 +540,31 @@ impl TelemetryHistory {
maximum_resolution: TimeDelta, maximum_resolution: TimeDelta,
telemetry_history_service: &TelemetryHistoryService, telemetry_history_service: &TelemetryHistoryService,
) -> Vec<TelemetryDataItem> { ) -> Vec<TelemetryDataItem> {
let mut result = vec![]; let mut disk_result = vec![];
let mut ram_result = vec![];
let segments = self.segments.read().await;
let mut from = from; let mut from = from;
let mut to = to;
let initial_to = to;
let mut ram_from_result = from;
{ {
let segments = self.segments.read().await;
let first_ram_segment = segments.front().map(|x| x.start); let first_ram_segment = segments.front().map(|x| x.start);
if let Some(first_ram_segment) = first_ram_segment {
let mut ram_from = first_ram_segment;
for i in 0..segments.len() {
let (new_from, new_data) = segments[i].get(ram_from, to, maximum_resolution);
ram_from = new_from;
ram_result.extend(new_data);
}
from = min(from, first_ram_segment);
to = min(to, first_ram_segment);
ram_from_result = ram_from;
}
}
{
let start = from let start = from
.duration_trunc(telemetry_history_service.segment_width) .duration_trunc(telemetry_history_service.segment_width)
.unwrap(); .unwrap();
@@ -549,12 +572,6 @@ impl TelemetryHistory {
.duration_trunc(telemetry_history_service.segment_width) .duration_trunc(telemetry_history_service.segment_width)
.unwrap(); .unwrap();
let end = if let Some(first_ram_segment) = first_ram_segment {
min(end, first_ram_segment)
} else {
end
};
let mut path = telemetry_history_service.data_root_folder.clone(); let mut path = telemetry_history_service.data_root_folder.clone();
path.push(&self.data.definition.uuid); path.push(&self.data.definition.uuid);
@@ -568,7 +585,7 @@ impl TelemetryHistory {
match disk.get(from, to, maximum_resolution, self.data.definition.data_type) { match disk.get(from, to, maximum_resolution, self.data.definition.data_type) {
Ok((new_from, new_data)) => { Ok((new_from, new_data)) => {
from = new_from; from = new_from;
result.extend(new_data); disk_result.extend(new_data);
} }
Err(err) => { Err(err) => {
error!("Failed to get from disk segment: {err}"); error!("Failed to get from disk segment: {err}");
@@ -579,13 +596,21 @@ impl TelemetryHistory {
} }
} }
{
// Go through the ram segments a second time to capture any data added since we dealt
// with the disk data
from = ram_from_result;
to = initial_to;
let segments = self.segments.read().await;
for i in 0..segments.len() { for i in 0..segments.len() {
let (new_from, new_data) = segments[i].get(from, to, maximum_resolution); let (new_from, new_data) = segments[i].get(from, to, maximum_resolution);
from = new_from; from = new_from;
result.extend(new_data); ram_result.extend(new_data);
}
} }
result disk_result.extend(ram_result);
disk_result
} }
pub async fn cleanup(&self, service: &TelemetryHistoryService) -> anyhow::Result<()> { pub async fn cleanup(&self, service: &TelemetryHistoryService) -> anyhow::Result<()> {

View File

@@ -146,6 +146,14 @@ impl TelemetryManagementService {
.cloned() .cloned()
} }
pub fn get_all_definitions(&self) -> Vec<TelemetryDefinition> {
let tlm_data = self.tlm_data.pin();
tlm_data
.values()
.map(|x| x.data.definition.clone())
.collect()
}
pub fn pin(&self) -> TelemetryManagementServicePin { pub fn pin(&self) -> TelemetryManagementServicePin {
TelemetryManagementServicePin { TelemetryManagementServicePin {
tlm_data: self.tlm_data.pin(), tlm_data: self.tlm_data.pin(),

View File

@@ -4,7 +4,7 @@ use rand::RngCore;
impl Uuid { impl Uuid {
pub fn random() -> Self { pub fn random() -> Self {
let mut uuid = [0u8; 16]; let mut uuid = [0u8; 16];
rand::thread_rng().fill_bytes(&mut uuid); rand::rng().fill_bytes(&mut uuid);
Self { Self {
value: hex::encode(uuid), value: hex::encode(uuid),
} }