adds initial user defined panels

This commit is contained in:
2025-12-23 16:41:21 -05:00
parent a110aa6376
commit ebbf864af9
33 changed files with 2188 additions and 370 deletions

1
.env Normal file
View File

@@ -0,0 +1 @@
DATABASE_URL=sqlite:data/database.db

2
.gitignore vendored
View File

@@ -1,4 +1,4 @@
/target /target
.idea/ .idea/
telemetry/ data/

1506
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,3 +2,5 @@
members = ["server", "examples/simple_producer"] members = ["server", "examples/simple_producer"]
resolver = "2" resolver = "2"
[profile.dev.package.sqlx-macros]
opt-level = 3

View File

@@ -110,3 +110,7 @@
justify-items: stretch; justify-items: stretch;
align-items: center; align-items: center;
} }
.justify-right {
justify-self: end;
}

View File

@@ -59,3 +59,26 @@ a:active {
text-decoration: inherit; text-decoration: inherit;
color: inherit; color: inherit;
} }
.panel-content {
flex-grow: 1;
max-height: 100vh;
}
.panel-content * {
border: transparent 1px solid;
min-width: 1em;
min-height: 1em;
}
.panel-content span {
display: inline-block;
}
.editable {
border: white 1px dashed;
}
.editable.selected {
border: yellow 1px solid;
}

View File

@@ -79,7 +79,12 @@ onUnmounted(() => {
<template> <template>
<span class="test monospace" :data-after-content="value"> <span class="test monospace" :data-after-content="value">
<span ref="data-span" class="transparent" :copy-value="value"> <span
ref="data-span"
class="transparent"
:copy-value="value"
v-bind="$attrs"
>
{{ overlay }} {{ overlay }}
</span> </span>
</span> </span>

View File

@@ -0,0 +1,218 @@
<script setup lang="ts">
import type { OptionalDynamicComponentData } from '@/composables/dynamic.ts';
import { computed, defineAsyncComponent } from 'vue';
const TelemetryValue = defineAsyncComponent(
() => import('@/components/TelemetryValue.vue'),
);
const GridLayout = defineAsyncComponent(
() => import('@/components/layout/GridLayout.vue'),
);
const model = defineModel<OptionalDynamicComponentData>('data', {
required: true,
});
const selection = defineModel<symbol>('selection');
const props = defineProps<{
editable: boolean;
}>();
const thisSymbol = Symbol();
const isSelected = computed(() => {
return selection.value == thisSymbol && props.editable;
});
function selectThis() {
selection.value = thisSymbol;
}
function deleteThis() {
model.value = {
type: 'none',
};
}
function makeText() {
model.value = {
type: 'text',
text: '',
justify_right: false,
};
}
function makeTelemetry() {
model.value = {
type: 'telemetry',
data: '',
};
}
function makeGrid() {
model.value = {
type: 'grid',
columns: 1,
equal_width: false,
cells: [],
};
}
function addRow() {
const grid = model.value;
if (grid.type == 'grid') {
const row: OptionalDynamicComponentData[] = [];
for (let i = 0; i < grid.columns; i++) {
row.push({ type: 'none' });
}
grid.cells.push(row);
model.value = grid;
}
}
function deleteRow() {
const grid = model.value;
if (grid.type == 'grid') {
grid.cells.pop();
model.value = grid;
}
}
function addColumn() {
const grid = model.value;
if (grid.type == 'grid') {
for (let i = 0; i < grid.cells.length; i++) {
grid.cells[i].push({ type: 'none' });
}
grid.columns += 1;
model.value = grid;
}
}
function deleteColumn() {
const grid = model.value;
if (grid.type == 'grid') {
for (let i = 0; i < grid.cells.length; i++) {
grid.cells[i].pop();
}
grid.columns -= 1;
model.value = grid;
}
}
</script>
<template>
<Teleport v-if="isSelected" to="#inspector">
<div class="row">
<button
v-if="model.type != 'none'"
@click.stop.prevent="deleteThis"
>
Delete
</button>
<button v-if="model.type != 'text'" @click.stop.prevent="makeText">
Make Text
</button>
<button
v-if="model.type != 'telemetry'"
@click.stop.prevent="makeTelemetry"
>
Make Telemetry
</button>
<button v-if="model.type != 'grid'" @click.stop.prevent="makeGrid">
Make Grid
</button>
</div>
</Teleport>
<template v-if="model.type == 'none'">
<!-- Intentionally Left Empty -->
<span
v-if="editable"
:class="`${editable ? 'editable' : ''} ${isSelected ? 'selected' : ''}`"
@click.stop.prevent="selectThis"
></span>
</template>
<template v-else-if="model.type == 'text'">
<span
:class="`${model.justify_right ? 'justify-right' : ''} ${editable ? 'editable' : ''} ${isSelected ? 'selected' : ''}`"
@click.stop.prevent="selectThis"
>
{{ model.text }}
</span>
<Teleport v-if="isSelected" to="#inspector">
<div class="row">
<label>Text: </label>
<input v-model="model.text" />
</div>
<div class="row">
<label>Justify Right: </label>
<input type="checkbox" v-model="model.justify_right" />
</div>
</Teleport>
</template>
<template v-else-if="model.type == 'telemetry'">
<span
v-if="editable"
:class="`${editable ? 'editable' : ''} ${isSelected ? 'selected' : ''}`"
@click.stop.prevent="selectThis"
>
{{ '{' }} {{ model.data }} {{ '}' }}
</span>
<TelemetryValue
v-else
:class="`${editable ? 'editable' : ''} ${isSelected ? 'selected' : ''}`"
:data="model.data"
@click.stop.prevent="selectThis"
></TelemetryValue>
<Teleport v-if="isSelected" to="#inspector">
<label>Telemetry Item: </label>
<input v-model="model.data" />
</Teleport>
</template>
<template v-else-if="model.type == 'grid'">
<GridLayout
:class="`${editable ? 'editable' : ''} ${isSelected ? 'selected' : ''}`"
:cols="model.columns"
:equal_col_width="model.equal_width"
@click.stop.prevent="selectThis"
>
<template v-for="x in model.cells.length" :key="x">
<template v-for="y in model.columns" :key="y">
<DynamicComponent
v-model:data="model.cells[x - 1][y - 1]"
:editable="editable"
v-model:selection="selection"
></DynamicComponent>
</template>
</template>
</GridLayout>
<Teleport v-if="isSelected" to="#inspector">
<div class="row">
<label>Equal Width: </label>
<input type="checkbox" v-model="model.equal_width" />
</div>
<div class="row">
<button @click.stop.prevent="addRow">Add Row</button>
<button
:disabled="model.cells.length <= 0"
@click.stop.prevent="deleteRow"
>
Delete Row
</button>
<button @click.stop.prevent="addColumn">Add Column</button>
<button
:disabled="model.columns <= 0"
@click.stop.prevent="deleteColumn"
>
Delete Column
</button>
</div>
</Teleport>
</template>
<template v-else> ERROR: Unknown data: {{ model }} </template>
</template>
<style scoped lang="scss">
@use '@/assets/variables';
</style>

View File

@@ -68,10 +68,15 @@ watch([display_value], ([display_str]) => {
<template> <template>
<template v-if="copyable"> <template v-if="copyable">
<CopyableDynamicSpan :value="display_value"></CopyableDynamicSpan> <CopyableDynamicSpan
:value="display_value"
v-bind="$attrs"
></CopyableDynamicSpan>
</template> </template>
<template v-else> <template v-else>
<span v-bind="$attrs">
{{ display_value }} {{ display_value }}
</span>
</template> </template>
</template> </template>

View File

@@ -48,14 +48,15 @@ const numeric_data = computed(() => {
</script> </script>
<template> <template>
<span v-if="!is_data_present"> No Data </span> <span v-if="!is_data_present" v-bind="$attrs"> No Data </span>
<template v-else> <template v-else>
<NumericText <NumericText
v-if="numeric_data" v-if="numeric_data"
v-bind="$attrs"
:value="numeric_data" :value="numeric_data"
:max_width="10" :max_width="10"
></NumericText> ></NumericText>
<span v-else> <span v-else v-bind="$attrs">
Cannot Display Data of Type {{ telemetry_data!.data_type }} Cannot Display Data of Type {{ telemetry_data!.data_type }}
</span> </span>
</template> </template>

View File

@@ -0,0 +1,13 @@
export type DynamicComponentData =
| { type: 'text'; text: string; justify_right: boolean }
| { type: 'telemetry'; data: string }
| {
type: 'grid';
columns: number;
equal_width: boolean;
cells: OptionalDynamicComponentData[][];
};
export type OptionalDynamicComponentData =
| { type: 'none' }
| DynamicComponentData;

View File

@@ -1,4 +1,5 @@
import type { RouteLocationRaw } from 'vue-router'; import type { RouteLocationRaw } from 'vue-router';
import { ref, type Ref, watchEffect } from 'vue';
export enum PanelHeirarchyType { export enum PanelHeirarchyType {
LEAF = 'leaf', LEAF = 'leaf',
@@ -22,31 +23,57 @@ export type PanelHeirarchyChildren = (
| PanelHeirarchyLeaf | PanelHeirarchyLeaf
)[]; )[];
export function getPanelHeirarchy(): PanelHeirarchyChildren { export function usePanelHeirarchy(): Ref<PanelHeirarchyChildren> {
const result: PanelHeirarchyChildren = []; const internal: PanelHeirarchyFolder = {
name: 'Internal',
result.push({ children: [
{
name: 'Graph Test', name: 'Graph Test',
to: { name: 'graph' }, to: { name: 'graph' },
type: PanelHeirarchyType.LEAF, type: PanelHeirarchyType.LEAF,
}); },
{
result.push({
name: 'Telemetry Elements', name: 'Telemetry Elements',
to: { name: 'list' }, to: { name: 'list' },
type: PanelHeirarchyType.LEAF, type: PanelHeirarchyType.LEAF,
}); },
{
result.push({
name: 'Chart', name: 'Chart',
to: { name: 'chart' }, to: { name: 'chart' },
type: PanelHeirarchyType.LEAF, type: PanelHeirarchyType.LEAF,
}); },
{
result.push({ name: 'Panel Editor',
name: 'Panel Test', to: { name: 'panel_editor' },
to: { name: 'panel_test' },
type: PanelHeirarchyType.LEAF, type: PanelHeirarchyType.LEAF,
},
],
type: PanelHeirarchyType.FOLDER,
};
const result: Ref<PanelHeirarchyChildren> = ref([internal]);
watchEffect(async () => {
try {
const res = await fetch(`/api/panel`);
const data = await res.json();
const server_panels: PanelHeirarchyFolder = {
name: 'Server Panels',
children: [],
type: PanelHeirarchyType.FOLDER,
};
for (const entry of data) {
server_panels.children.push({
name: entry['name'],
to: { name: 'panel', params: { id: entry['id'] } },
type: PanelHeirarchyType.LEAF,
});
}
result.value = [internal, server_panels];
} catch {}
}); });
return result; return result;

View File

@@ -24,9 +24,14 @@ const router = createRouter({
component: () => import('../views/ChartView.vue'), component: () => import('../views/ChartView.vue'),
}, },
{ {
path: '/panel_test', path: '/panel_editor',
name: 'panel_test', name: 'panel_editor',
component: () => import('../views/PanelTest.vue'), component: () => import('../views/PanelEditorView.vue'),
},
{
path: '/panel/:id',
name: 'panel',
component: () => import('../views/PanelView.vue'),
}, },
], ],
}); });

View File

@@ -3,7 +3,7 @@ import { computed, ref } from 'vue';
import { import {
filterHeirarchy, filterHeirarchy,
getFirstLeaf, getFirstLeaf,
getPanelHeirarchy, usePanelHeirarchy,
} from '@/panels/panel'; } from '@/panels/panel';
import PanelHeirarchy from '@/components/PanelHeirarchy.vue'; import PanelHeirarchy from '@/components/PanelHeirarchy.vue';
import router from '@/router'; import router from '@/router';
@@ -14,10 +14,10 @@ import { ScreenType } from '@/composables/ScreenType.ts';
const searchValue = ref(''); const searchValue = ref('');
const heirarchy = getPanelHeirarchy(); const heirarchy = usePanelHeirarchy();
const filtered_heirarchy = computed(() => const filtered_heirarchy = computed(() =>
filterHeirarchy(heirarchy, (leaf) => { filterHeirarchy(heirarchy.value, (leaf) => {
return leaf.name return leaf.name
.toLowerCase() .toLowerCase()
.includes(searchValue.value.toLowerCase()); .includes(searchValue.value.toLowerCase());

View File

@@ -0,0 +1,129 @@
<script setup lang="ts">
import DynamicComponent from '@/components/DynamicComponent.vue';
import type { OptionalDynamicComponentData } from '@/composables/dynamic.ts';
import { ref, watchEffect } from 'vue';
const data = ref<OptionalDynamicComponentData>({ type: 'none' });
const panel_id = ref<string | null>(null);
const panel_name = ref<string>('Untitled Panel');
const loading = ref(false);
const selected = ref<symbol>();
const panel_list = ref<{ name: string; id: string }[]>([]);
const load_screen = ref(true);
const unselect = () => {
selected.value = undefined;
};
async function reload_panel_list() {
const res = await fetch(`/api/panel`);
panel_list.value = await res.json();
}
watchEffect(reload_panel_list);
async function load(id: string) {
data.value = { type: 'none' };
load_screen.value = false;
loading.value = true;
const panel_data = await fetch(`/api/panel/${id}`);
const panel_json_value = await panel_data.json();
data.value = JSON.parse(
panel_json_value['data'],
) as OptionalDynamicComponentData;
panel_name.value = panel_json_value['name'];
panel_id.value = id;
loading.value = false;
}
async function newPanel() {
data.value = { type: 'none' };
panel_name.value = 'Untitled Panel';
panel_id.value = null;
loading.value = false;
load_screen.value = false;
}
async function save() {
loading.value = true;
const panel_id_value = panel_id.value;
if (panel_id_value) {
const res = await fetch(`/api/panel/${panel_id_value}`, {
method: 'PUT',
body: JSON.stringify({
name: panel_name.value,
data: JSON.stringify(data.value),
}),
headers: {
'Content-Type': 'application/json',
},
});
await res.json();
} else {
const res = await fetch('/api/panel', {
method: 'POST',
body: JSON.stringify({
name: panel_name.value,
data: JSON.stringify(data.value),
}),
headers: {
'Content-Type': 'application/json',
},
});
const uuid = await res.json();
panel_id.value = uuid as string;
}
loading.value = false;
}
async function showLoadScreen() {
load_screen.value = true;
await reload_panel_list();
}
</script>
<template>
<div v-if="load_screen" class="column grow">
<div class="row">
<button @click.stop.prevent="newPanel">New</button>
</div>
<div v-for="panel of panel_list" :key="panel.id" class="row">
<button @click.stop.prevent="() => load(panel.id)">Load</button>
<span>
{{ panel.name }}
</span>
</div>
</div>
<div v-else class="row grow">
<div class="panel-content scroll no-min-height" @click="unselect">
<DynamicComponent
v-model:data="data"
:editable="true"
v-model:selection="selected"
></DynamicComponent>
</div>
<div id="sidebar" class="column">
<div class="row">
<label>Name:</label>
<input :disabled="loading" type="text" v-model="panel_name" />
</div>
<div class="row">
<button :disabled="loading" @click.prevent.stop="save">
{{ panel_id ? 'Save' : 'New' }}
</button>
<button @click.prevent.stop="showLoadScreen">Load</button>
</div>
<div id="inspector" class="column"></div>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/assets/variables';
#sidebar {
width: 25%;
height: 100vh;
background: variables.$dark-background-color;
}
</style>

View File

@@ -1,28 +0,0 @@
<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,30 @@
<script setup lang="ts">
import DynamicComponent from '@/components/DynamicComponent.vue';
import type { OptionalDynamicComponentData } from '@/composables/dynamic.ts';
import { computed, ref, watchEffect } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const id = computed<string>(() => route.params.id as string);
const panel = ref<OptionalDynamicComponentData>({
type: 'none',
});
watchEffect(async () => {
const panel_data = await fetch(`/api/panel/${id.value}`);
const panel_json_value = await panel_data.json();
panel.value = JSON.parse(
panel_json_value['data'],
) as OptionalDynamicComponentData;
});
</script>
<template>
<DynamicComponent v-model:data="panel" :editable="false"></DynamicComponent>
</template>
<style scoped lang="scss">
@use '@/assets/variables';
</style>

View File

@@ -7,22 +7,24 @@ authors = ["Sergey <me@sergeysav.com>"]
[dependencies] [dependencies]
fern = "0.7.1" fern = "0.7.1"
log = "0.4.25" log = "0.4.29"
prost = "0.13.5" prost = "0.13.5"
rand = "0.9.0" rand = "0.9.0"
tonic = { version = "0.12.3" } tonic = { version = "0.12.3" }
tokio = { version = "1.43.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.42"
actix-web = "4.9.0" actix-web = { version = "4.12.1", features = [ ] }
actix-ws = "0.3.0" actix-ws = "0.3.0"
tokio-util = "0.7.13" tokio-util = "0.7.17"
serde = { version = "1.0.217", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.138" serde_json = "1.0.145"
hex = "0.4.3" hex = "0.4.3"
papaya = "0.1.8" papaya = "0.2.3"
thiserror = "2.0.11" thiserror = "2.0.17"
derive_more = { version = "2.0.1", features = ["from"] } derive_more = { version = "2.1.0", features = ["from"] }
anyhow = "1.0.95" anyhow = "1.0.100"
sqlx = { version = "0.8.6", features = [ "runtime-tokio", "tls-native-tls", "sqlite" ] }
uuid = { version = "1.19.0", features = ["v4"] }
[build-dependencies] [build-dependencies]
tonic-build = "0.12.3" tonic-build = "0.12.3"

View File

@@ -1,4 +1,5 @@
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("cargo:rerun-if-changed=migrations");
tonic_build::compile_protos("proto/core.proto")?; tonic_build::compile_protos("proto/core.proto")?;
Ok(()) Ok(())
} }

View File

@@ -0,0 +1,7 @@
CREATE TABLE PANELS (
id VARCHAR(255) PRIMARY KEY NOT NULL,
name VARCHAR(255) NOT NULL,
data TEXT NOT NULL,
deleted BOOLEAN NOT NULL
);

View File

@@ -86,6 +86,7 @@ impl TelemetryService for CoreTelemetryService {
} }
impl CoreTelemetryService { impl CoreTelemetryService {
#[allow(clippy::result_large_err)]
fn handle_new_tlm_item( fn handle_new_tlm_item(
tlm_management: &Arc<TelemetryManagementService>, tlm_management: &Arc<TelemetryManagementService>,
tlm_item: &TelemetryItem, tlm_item: &TelemetryItem,

View File

@@ -1,85 +1,15 @@
use crate::http::error::HttpServerResultError; mod panels;
use crate::telemetry::management_service::TelemetryManagementService; mod tlm;
use actix_web::{get, web, Responder};
use chrono::{DateTime, TimeDelta, Utc};
use log::trace;
use serde::Deserialize;
use std::sync::Arc;
use std::time::Duration;
use tokio::time::timeout;
#[get("/tlm/info/{name:[\\w\\d/_-]+}")] use actix_web::web;
async fn get_tlm_definition(
data: web::Data<Arc<TelemetryManagementService>>,
name: web::Path<String>,
) -> Result<impl Responder, HttpServerResultError> {
let string = name.to_string();
trace!("get_tlm_definition {}", string);
let Some(data) = data.get_by_name(&string) else {
return Err(HttpServerResultError::TlmNameNotFound { tlm: string });
};
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)]
struct HistoryQuery {
from: String,
to: String,
resolution: i64,
}
#[get("/tlm/history/{uuid:[0-9a-f]+}")]
async fn get_tlm_history(
data_arc: web::Data<Arc<TelemetryManagementService>>,
uuid: web::Path<String>,
info: web::Query<HistoryQuery>,
) -> Result<impl Responder, HttpServerResultError> {
let uuid = uuid.to_string();
trace!(
"get_tlm_history {} from {} to {} resolution {}",
uuid,
info.from,
info.to,
info.resolution
);
let Ok(from) = info.from.parse::<DateTime<Utc>>() else {
return Err(HttpServerResultError::InvalidDateTime {
date_time: info.from.clone(),
});
};
let Ok(to) = info.to.parse::<DateTime<Utc>>() else {
return Err(HttpServerResultError::InvalidDateTime {
date_time: info.to.clone(),
});
};
let maximum_resolution = TimeDelta::milliseconds(info.resolution);
let history_service = data_arc.history_service();
let data = data_arc.pin();
match data.get_by_uuid(&uuid) {
None => Err(HttpServerResultError::TlmUuidNotFound { uuid }),
Some(tlm) => timeout(
Duration::from_secs(10),
tlm.get(from, to, maximum_resolution, &history_service),
)
.await
.map(|result| Ok(web::Json(result)))
.unwrap_or_else(|_| Err(HttpServerResultError::Timeout)),
}
}
pub fn setup_api(cfg: &mut web::ServiceConfig) { pub fn setup_api(cfg: &mut web::ServiceConfig) {
cfg.service(get_all_tlm_definitions) cfg.service(tlm::get_all_tlm_definitions)
.service(get_tlm_definition) .service(tlm::get_tlm_definition)
.service(get_tlm_history); .service(tlm::get_tlm_history)
.service(panels::new)
.service(panels::get_all)
.service(panels::get_one)
.service(panels::set)
.service(panels::delete);
} }

View File

@@ -0,0 +1,67 @@
use crate::http::error::HttpServerResultError;
use crate::panels::panel::PanelUpdate;
use crate::panels::PanelService;
use actix_web::{delete, get, post, put, web, Responder};
use serde::Deserialize;
use std::sync::Arc;
#[derive(Deserialize)]
struct CreateParam {
name: String,
data: String,
}
#[derive(Deserialize)]
struct IdParam {
id: String,
}
#[post("/panel")]
pub(super) async fn new(
panels: web::Data<Arc<PanelService>>,
data: web::Json<CreateParam>,
) -> Result<impl Responder, HttpServerResultError> {
let uuid = panels.create(&data.name, &data.data).await?;
Ok(web::Json(uuid.value))
}
#[get("/panel")]
pub(super) async fn get_all(
panels: web::Data<Arc<PanelService>>,
) -> Result<impl Responder, HttpServerResultError> {
let result = panels.read_all().await?;
Ok(web::Json(result))
}
#[get("/panel/{id}")]
pub(super) async fn get_one(
panels: web::Data<Arc<PanelService>>,
path: web::Path<IdParam>,
) -> Result<impl Responder, HttpServerResultError> {
let result = panels.read(path.id.clone().into()).await?;
match result {
Some(result) => Ok(web::Json(result)),
None => Err(HttpServerResultError::PanelUuidNotFound {
uuid: path.id.clone(),
}),
}
}
#[put("/panel/{id}")]
pub(super) async fn set(
panels: web::Data<Arc<PanelService>>,
path: web::Path<IdParam>,
data: web::Json<PanelUpdate>,
) -> Result<impl Responder, HttpServerResultError> {
panels.update(path.id.clone().into(), data.0).await?;
Ok(web::Json(()))
}
#[delete("/panel/{id}")]
pub(super) async fn delete(
panels: web::Data<Arc<PanelService>>,
path: web::Path<IdParam>,
) -> Result<impl Responder, HttpServerResultError> {
panels.delete(path.id.clone().into()).await?;
Ok(web::Json(()))
}

View File

@@ -0,0 +1,79 @@
use crate::http::error::HttpServerResultError;
use crate::telemetry::management_service::TelemetryManagementService;
use actix_web::{get, web, Responder};
use chrono::{DateTime, TimeDelta, Utc};
use log::trace;
use serde::Deserialize;
use std::sync::Arc;
use std::time::Duration;
use tokio::time::timeout;
#[get("/tlm/info/{name:[\\w\\d/_-]+}")]
pub(super) async fn get_tlm_definition(
data: web::Data<Arc<TelemetryManagementService>>,
name: web::Path<String>,
) -> Result<impl Responder, HttpServerResultError> {
let string = name.to_string();
trace!("get_tlm_definition {}", string);
let Some(data) = data.get_by_name(&string) else {
return Err(HttpServerResultError::TlmNameNotFound { tlm: string });
};
Ok(web::Json(data.definition.clone()))
}
#[get("/tlm/info")]
pub(super) 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)]
struct HistoryQuery {
from: String,
to: String,
resolution: i64,
}
#[get("/tlm/history/{uuid:[0-9a-f]+}")]
pub(super) async fn get_tlm_history(
data_arc: web::Data<Arc<TelemetryManagementService>>,
uuid: web::Path<String>,
info: web::Query<HistoryQuery>,
) -> Result<impl Responder, HttpServerResultError> {
let uuid = uuid.to_string();
trace!(
"get_tlm_history {} from {} to {} resolution {}",
uuid,
info.from,
info.to,
info.resolution
);
let Ok(from) = info.from.parse::<DateTime<Utc>>() else {
return Err(HttpServerResultError::InvalidDateTime {
date_time: info.from.clone(),
});
};
let Ok(to) = info.to.parse::<DateTime<Utc>>() else {
return Err(HttpServerResultError::InvalidDateTime {
date_time: info.to.clone(),
});
};
let maximum_resolution = TimeDelta::milliseconds(info.resolution);
let history_service = data_arc.history_service();
let data = data_arc.pin();
match data.get_by_uuid(&uuid) {
None => Err(HttpServerResultError::TlmUuidNotFound { uuid }),
Some(tlm) => timeout(
Duration::from_secs(10),
tlm.get(from, to, maximum_resolution, &history_service),
)
.await
.map(|result| Ok(web::Json(result)))
.unwrap_or_else(|_| Err(HttpServerResultError::Timeout)),
}
}

View File

@@ -2,11 +2,9 @@ use actix_web::error::ResponseError;
use actix_web::http::header::ContentType; use actix_web::http::header::ContentType;
use actix_web::http::StatusCode; use actix_web::http::StatusCode;
use actix_web::HttpResponse; use actix_web::HttpResponse;
use anyhow::Error;
use thiserror::Error; use thiserror::Error;
#[derive(Error, Debug)]
pub enum WebsocketResponseError {}
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum HttpServerResultError { pub enum HttpServerResultError {
#[error("Telemetry Name Not Found: {tlm}")] #[error("Telemetry Name Not Found: {tlm}")]
@@ -17,6 +15,10 @@ pub enum HttpServerResultError {
InvalidDateTime { date_time: String }, InvalidDateTime { date_time: String },
#[error("Timed out")] #[error("Timed out")]
Timeout, Timeout,
#[error("Internal Error")]
InternalError(anyhow::Error),
#[error("Panel Uuid Not Found: {uuid}")]
PanelUuidNotFound { uuid: String },
} }
impl ResponseError for HttpServerResultError { impl ResponseError for HttpServerResultError {
@@ -25,7 +27,9 @@ impl ResponseError for HttpServerResultError {
HttpServerResultError::TlmNameNotFound { .. } => StatusCode::NOT_FOUND, HttpServerResultError::TlmNameNotFound { .. } => StatusCode::NOT_FOUND,
HttpServerResultError::TlmUuidNotFound { .. } => StatusCode::NOT_FOUND, HttpServerResultError::TlmUuidNotFound { .. } => StatusCode::NOT_FOUND,
HttpServerResultError::InvalidDateTime { .. } => StatusCode::BAD_REQUEST, HttpServerResultError::InvalidDateTime { .. } => StatusCode::BAD_REQUEST,
HttpServerResultError::Timeout { .. } => StatusCode::GATEWAY_TIMEOUT, HttpServerResultError::Timeout => StatusCode::GATEWAY_TIMEOUT,
HttpServerResultError::InternalError { .. } => StatusCode::INTERNAL_SERVER_ERROR,
HttpServerResultError::PanelUuidNotFound { .. } => StatusCode::NOT_FOUND,
} }
} }
fn error_response(&self) -> HttpResponse { fn error_response(&self) -> HttpResponse {
@@ -34,3 +38,9 @@ impl ResponseError for HttpServerResultError {
.body(self.to_string()) .body(self.to_string())
} }
} }
impl From<anyhow::Error> for HttpServerResultError {
fn from(value: Error) -> Self {
Self::InternalError(value)
}
}

View File

@@ -4,7 +4,9 @@ mod websocket;
use crate::http::api::setup_api; use crate::http::api::setup_api;
use crate::http::websocket::setup_websocket; use crate::http::websocket::setup_websocket;
use crate::panels::PanelService;
use crate::telemetry::management_service::TelemetryManagementService; use crate::telemetry::management_service::TelemetryManagementService;
use actix_web::middleware::Logger;
use actix_web::{web, App, HttpServer}; use actix_web::{web, App, HttpServer};
use log::info; use log::info;
use std::sync::Arc; use std::sync::Arc;
@@ -13,17 +15,21 @@ use tokio_util::sync::CancellationToken;
pub async fn setup( pub async fn setup(
cancellation_token: CancellationToken, cancellation_token: CancellationToken,
telemetry_definitions: Arc<TelemetryManagementService>, telemetry_definitions: Arc<TelemetryManagementService>,
panel_service: PanelService,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let data = web::Data::new(telemetry_definitions); let data = web::Data::new(telemetry_definitions);
let cancel_token = web::Data::new(cancellation_token); let cancel_token = web::Data::new(cancellation_token);
let panel_service = web::Data::new(Arc::new(panel_service));
info!("Starting HTTP Server"); info!("Starting HTTP Server");
HttpServer::new(move || { HttpServer::new(move || {
App::new() App::new()
.app_data(data.clone()) .app_data(data.clone())
.app_data(cancel_token.clone()) .app_data(cancel_token.clone())
.app_data(panel_service.clone())
.service(web::scope("/ws").configure(setup_websocket)) .service(web::scope("/ws").configure(setup_websocket))
.service(web::scope("/api").configure(setup_api)) .service(web::scope("/api").configure(setup_api))
.wrap(Logger::default())
}) })
.bind("localhost:8080")? .bind("localhost:8080")?
.run() .run()

View File

@@ -1,5 +1,6 @@
mod grpc; mod grpc;
mod http; mod http;
mod panels;
mod serialization; mod serialization;
mod telemetry; mod telemetry;
mod uuid; mod uuid;
@@ -8,9 +9,13 @@ pub mod core {
tonic::include_proto!("core"); tonic::include_proto!("core");
} }
use crate::panels::PanelService;
use crate::telemetry::history::TelemetryHistoryService; use crate::telemetry::history::TelemetryHistoryService;
use crate::telemetry::management_service::TelemetryManagementService; use crate::telemetry::management_service::TelemetryManagementService;
use log::error; use log::{error, info};
use sqlx::sqlite::SqliteConnectOptions;
use sqlx::SqlitePool;
use std::path;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::time::sleep; use tokio::time::sleep;
@@ -26,18 +31,36 @@ pub async fn setup() -> anyhow::Result<()> {
}); });
} }
let data_folder = path::absolute("data")?;
let telemetry_folder = data_folder.join("telemetry");
let database_url = data_folder.join("database.db");
info!("Opening Database: {database_url:?}");
let sqlite = SqlitePool::connect_with(
SqliteConnectOptions::new()
.filename(database_url)
.create_if_missing(true),
)
.await?;
sqlx::migrate!().run(&sqlite).await?;
let tlm = Arc::new(TelemetryManagementService::new( let tlm = Arc::new(TelemetryManagementService::new(
TelemetryHistoryService::new()?, TelemetryHistoryService::new(telemetry_folder)?,
)?); )?);
let grpc_server = grpc::setup(cancellation_token.clone(), tlm.clone())?; let grpc_server = grpc::setup(cancellation_token.clone(), tlm.clone())?;
let result = http::setup(cancellation_token.clone(), tlm.clone()).await; let panel_service = PanelService::new(sqlite.clone());
let result = http::setup(cancellation_token.clone(), tlm.clone(), panel_service).await;
cancellation_token.cancel(); cancellation_token.cancel();
result?; // result is dropped result?; // result is dropped
grpc_server.await?; //grpc server is dropped grpc_server.await?; //grpc server is dropped
drop(cancellation_token); // All cancellation tokens are now dropped drop(cancellation_token); // All cancellation tokens are now dropped
sqlite.close().await;
// Perform cleanup functions - at this point all servers have stopped and we can be sure that cleaning things up is safe // 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 { for _ in 0..15 {
if Arc::strong_count(&tlm) != 1 { if Arc::strong_count(&tlm) != 1 {

View File

@@ -21,6 +21,7 @@ async fn main() -> anyhow::Result<()> {
}) })
.level(log::LevelFilter::Warn) .level(log::LevelFilter::Warn)
.level_for("server", log_level) .level_for("server", log_level)
.level_for("actix_web::middleware::logger", log_level)
.chain(std::io::stdout()); .chain(std::io::stdout());
if let Ok(log_file) = log_file { if let Ok(log_file) = log_file {
log_config = log_config.chain(fern::log_file(log_file)?) log_config = log_config.chain(fern::log_file(log_file)?)

130
server/src/panels/mod.rs Normal file
View File

@@ -0,0 +1,130 @@
pub mod panel;
use crate::core::Uuid;
use crate::panels::panel::{PanelRequired, PanelUpdate};
use panel::Panel;
use sqlx::SqlitePool;
pub struct PanelService {
pool: SqlitePool,
}
impl PanelService {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
pub async fn create(&self, name: &str, data: &str) -> anyhow::Result<Uuid> {
let id = Uuid::random();
let mut transaction = self.pool.begin().await?;
let _ = sqlx::query!(
r#"
INSERT INTO PANELS (id, name, data, deleted)
VALUES ($1, $2, $3, FALSE);
"#,
id.value,
name,
data
)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(id)
}
pub async fn read_all(&self) -> anyhow::Result<Vec<PanelRequired>> {
let mut transaction = self.pool.begin().await?;
let panels = sqlx::query_as!(
PanelRequired,
r#"
SELECT id, name
FROM PANELS
WHERE deleted = FALSE
"#,
)
.fetch_all(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(panels)
}
pub async fn read(&self, id: Uuid) -> anyhow::Result<Option<Panel>> {
let mut transaction = self.pool.begin().await?;
let panel = sqlx::query_as(
r#"
SELECT id, name, data
FROM PANELS
WHERE id = $1 AND deleted = FALSE
"#,
)
.bind(id.value)
.fetch_optional(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(panel)
}
pub async fn update(&self, id: Uuid, data: PanelUpdate) -> anyhow::Result<()> {
let mut transaction = self.pool.begin().await?;
if let Some(name) = data.name {
let _ = sqlx::query!(
r#"
UPDATE PANELS
SET name = $2
WHERE id = $1;
"#,
id.value,
name
)
.execute(&mut *transaction)
.await?;
}
if let Some(data) = data.data {
let _ = sqlx::query!(
r#"
UPDATE PANELS
SET data = $2
WHERE id = $1;
"#,
id.value,
data
)
.execute(&mut *transaction)
.await?;
}
transaction.commit().await?;
Ok(())
}
pub async fn delete(&self, id: Uuid) -> anyhow::Result<()> {
let mut transaction = self.pool.begin().await?;
let _ = sqlx::query!(
r#"
UPDATE PANELS
SET deleted = TRUE
WHERE id = $1;
"#,
id.value,
)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(())
}
}

View File

@@ -0,0 +1,22 @@
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
#[derive(Clone, FromRow, Serialize, Deserialize)]
pub struct PanelRequired {
pub id: String,
pub name: String,
}
#[derive(Clone, FromRow, Serialize, Deserialize)]
pub struct Panel {
#[sqlx(flatten)]
#[serde(flatten)]
pub header: PanelRequired,
pub data: String,
}
#[derive(Default, Clone, Serialize, Deserialize)]
pub struct PanelUpdate {
pub name: Option<String>,
pub data: Option<String>,
}

View File

@@ -9,11 +9,11 @@ use chrono::{DateTime, DurationRound, SecondsFormat, TimeDelta, Utc};
use log::{error, info}; use log::{error, info};
use std::cmp::min; use std::cmp::min;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::fs;
use std::fs::File; use std::fs::File;
use std::io::{BufReader, BufWriter, 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 tokio::task::{spawn_blocking, JoinHandle}; use tokio::task::{spawn_blocking, JoinHandle};
const FOLDER_DURATION: TimeDelta = TimeDelta::hours(1); const FOLDER_DURATION: TimeDelta = TimeDelta::hours(1);
@@ -484,7 +484,7 @@ impl TelemetryHistory {
drop(segments); drop(segments);
let mut segments = self.segments.write().await; let mut segments = self.segments.write().await;
if segments.len() == 0 { if segments.is_empty() {
let start_time = timestamp.duration_trunc(service.segment_width).unwrap(); let start_time = timestamp.duration_trunc(service.segment_width).unwrap();
segments.push_back( segments.push_back(
self.create_ram_segment(start_time, service, self.data.definition.data_type) self.create_ram_segment(start_time, service, self.data.definition.data_type)
@@ -636,11 +636,11 @@ pub struct TelemetryHistoryService {
} }
impl TelemetryHistoryService { impl TelemetryHistoryService {
pub fn new() -> anyhow::Result<Self> { pub fn new(data_folder: PathBuf) -> anyhow::Result<Self> {
let result = Self { let result = Self {
segment_width: TimeDelta::minutes(1), segment_width: TimeDelta::minutes(1),
max_segments: 5, max_segments: 5,
data_root_folder: path::absolute("telemetry")?, data_root_folder: data_folder,
}; };
fs::create_dir_all(&result.data_root_folder)?; fs::create_dir_all(&result.data_root_folder)?;
@@ -654,8 +654,6 @@ impl TelemetryHistoryService {
} }
pub fn get_metadata_file(&self) -> PathBuf { pub fn get_metadata_file(&self) -> PathBuf {
let mut result = self.data_root_folder.clone(); self.data_root_folder.join("metadata.json")
result.push("metadata.json");
result
} }
} }

View File

@@ -154,7 +154,7 @@ impl TelemetryManagementService {
.collect() .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

@@ -10,3 +10,9 @@ impl Uuid {
} }
} }
} }
impl From<String> for Uuid {
fn from(value: String) -> Self {
Self { value }
}
}