demo version prepped

This commit is contained in:
2026-04-01 12:40:40 -04:00
parent d44e5f0ad1
commit ed319a6423
62 changed files with 8362 additions and 0 deletions

3
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<RouterView />
</template>

View File

@@ -0,0 +1,27 @@
import { store } from './store'
export const get_request = (url: string) => {
// TODO: Progress bar
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('GET', url)
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.onload = () => {
if (xhr.status === 200) {
const data = JSON.parse(xhr.responseText)
resolve(data)
} else {
console.error('Failed to get request data', xhr.status)
reject(xhr.status)
}
}
xhr.onerror = () => {
console.error('Request error')
reject('Network error')
}
xhr.send()
})
}

View File

@@ -0,0 +1,83 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
@font-face {
font-family: 'Departure Mono';
src: url('./fonts/DepartureMono.woff2') format('woff2');
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
'Departure Mono',
'monospace'
;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
margin-bottom: 20rem;
}

Binary file not shown.

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

View File

@@ -0,0 +1,87 @@
@import './base.css';
:root {
--theme: rgba(255, 100, 0, 1);
--theme-dim: rgba(255, 100, 0, 0.6);
--theme-faint: rgba(255, 100, 0, 0.3);
--theme-bg: rgba(255, 100, 0, 0.15);
--theme-glow: rgba(255, 100, 0, 0.2);
}
.mode_historical {
--theme: rgba(200, 160, 100, 1);
--theme-dim: rgba(200, 160, 100, 0.6);
--theme-faint: rgba(200, 160, 100, 0.3);
--theme-bg: rgba(200, 160, 100, 0.15);
--theme-glow: rgba(200, 160, 100, 0.2);
}
.mode_frozen {
--theme: rgba(150, 220, 255, 1);
--theme-dim: rgba(150, 220, 255, 0.6);
--theme-faint: rgba(150, 220, 255, 0.3);
--theme-bg: rgba(150, 220, 255, 0.15);
--theme-glow: rgba(150, 220, 255, 0.2);
}
#app {
margin: 0 auto;
padding: 2rem 2rem;
}
.tab_btn {
background: transparent;
border: 1px solid var(--theme-faint);
color: var(--theme-dim);
padding: 0.4rem 1.2rem;
cursor: pointer;
font-family: 'Departure Mono', monospace;
font-size: 0.85rem;
border-radius: 4px;
transition: all 0.2s;
margin-right: 0.5rem;
}
.tab_btn:hover {
border-color: var(--theme);
color: var(--theme);
}
.tab_btn.active {
background-color: var(--theme-bg);
border-color: var(--theme);
color: var(--theme);
box-shadow: 0 0 8px var(--theme-glow);
}
a,
.amber {
text-decoration: none;
color: rgba(255, 100, 0, 1);
transition: 0.4s;
padding: 3px;
}
a {
font-style: italic;
}
a:hover {
color: rgba(180, 70, 0, 1);
}
@media (hover: hover) {
a:hover {
background-color: rgba(180, 70, 0, 1);
color: white;
}
}
@media (min-width: 1024px) {
main {
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
}

349
frontend/src/assets/normalize.css vendored Normal file
View File

@@ -0,0 +1,349 @@
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
/* Document
========================================================================== */
/**
* 1. Correct the line height in all browsers.
* 2. Prevent adjustments of font size after orientation changes in iOS.
*/
html {
line-height: 1.15; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
}
/* Sections
========================================================================== */
/**
* Remove the margin in all browsers.
*/
body {
margin: 0;
}
/**
* Render the `main` element consistently in IE.
*/
main {
display: block;
}
/**
* Correct the font size and margin on `h1` elements within `section` and
* `article` contexts in Chrome, Firefox, and Safari.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/* Grouping content
========================================================================== */
/**
* 1. Add the correct box sizing in Firefox.
* 2. Show the overflow in Edge and IE.
*/
hr {
box-sizing: content-box; /* 1 */
height: 0; /* 1 */
overflow: visible; /* 2 */
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
pre {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/* Text-level semantics
========================================================================== */
/**
* Remove the gray background on active links in IE 10.
*/
a {
background-color: transparent;
}
/**
* 1. Remove the bottom border in Chrome 57-
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
*/
abbr[title] {
border-bottom: none; /* 1 */
text-decoration: underline; /* 2 */
text-decoration: underline dotted; /* 2 */
}
/**
* Add the correct font weight in Chrome, Edge, and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
* Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/**
* Prevent `sub` and `sup` elements from affecting the line height in
* all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/* Embedded content
========================================================================== */
/**
* Remove the border on images inside links in IE 10.
*/
img {
border-style: none;
}
/* Forms
========================================================================== */
/**
* 1. Change the font styles in all browsers.
* 2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
}
/**
* Show the overflow in IE.
* 1. Show the overflow in Edge.
*/
button,
input { /* 1 */
overflow: visible;
}
/**
* Remove the inheritance of text transform in Edge, Firefox, and IE.
* 1. Remove the inheritance of text transform in Firefox.
*/
button,
select { /* 1 */
text-transform: none;
}
/**
* Correct the inability to style clickable types in iOS and Safari.
*/
button,
[type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
/**
* Remove the inner border and padding in Firefox.
*/
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
/**
* Restore the focus styles unset by the previous rule.
*/
button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
outline: 1px dotted ButtonText;
}
/**
* Correct the padding in Firefox.
*/
fieldset {
padding: 0.35em 0.75em 0.625em;
}
/**
* 1. Correct the text wrapping in Edge and IE.
* 2. Correct the color inheritance from `fieldset` elements in IE.
* 3. Remove the padding so developers are not caught out when they zero out
* `fieldset` elements in all browsers.
*/
legend {
box-sizing: border-box; /* 1 */
color: inherit; /* 2 */
display: table; /* 1 */
max-width: 100%; /* 1 */
padding: 0; /* 3 */
white-space: normal; /* 1 */
}
/**
* Add the correct vertical alignment in Chrome, Firefox, and Opera.
*/
progress {
vertical-align: baseline;
}
/**
* Remove the default vertical scrollbar in IE 10+.
*/
textarea {
overflow: auto;
}
/**
* 1. Add the correct box sizing in IE 10.
* 2. Remove the padding in IE 10.
*/
[type="checkbox"],
[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
* Correct the cursor style of increment and decrement buttons in Chrome.
*/
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
/**
* 1. Correct the odd appearance in Chrome and Safari.
* 2. Correct the outline style in Safari.
*/
[type="search"] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
* Remove the inner padding in Chrome and Safari on macOS.
*/
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* 1. Correct the inability to style clickable types in iOS and Safari.
* 2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/* Interactive
========================================================================== */
/*
* Add the correct display in Edge, IE 10+, and Firefox.
*/
details {
display: block;
}
/*
* Add the correct display in all browsers.
*/
summary {
display: list-item;
}
/* Misc
========================================================================== */
/**
* Add the correct display in IE 10+.
*/
template {
display: none;
}
/**
* Add the correct display in IE 10.
*/
[hidden] {
display: none;
}

View File

@@ -0,0 +1,169 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const props = defineProps<{
data: []
}>()
const assigned = computed(() => props.data.filter(a => a.incident_id != null))
const unassigned = computed(() => props.data.filter(a => a.incident_id == null))
</script>
<template>
<div class="alarm_groups">
<div v-if="unassigned.length > 0" class="alarm_group">
<div class="group_label">UNASSIGNED <span class="group_count">{{ unassigned.length }}</span></div>
<ul class="alarm_list">
<li v-for="item in unassigned" :key="item.id"
class="alarm_item" :class="`sev_${item.severity}`"
@click="router.push(`/cellsites/${item.site_id}`)">
<div class="alarm_left">
<span class="alarm_sev">SEV {{ item.severity }}</span>
<span class="alarm_site">SITE {{ item.site_id }}</span>
</div>
<div class="alarm_body">
<span class="alarm_text">{{ item.text }}</span>
<span class="alarm_time">{{ item.created }}</span>
</div>
</li>
</ul>
</div>
<div v-if="assigned.length > 0" class="alarm_group">
<div class="group_label">ASSIGNED <span class="group_count">{{ assigned.length }}</span></div>
<ul class="alarm_list">
<li v-for="item in assigned" :key="item.id"
class="alarm_item assigned" :class="`sev_${item.severity}`"
@click="router.push(`/cellsites/${item.site_id}`)">
<div class="alarm_left">
<span class="alarm_sev">SEV {{ item.severity }}</span>
<span class="alarm_site">SITE {{ item.site_id }}</span>
</div>
<div class="alarm_body">
<span class="alarm_text">{{ item.text }}</span>
<span class="alarm_time">{{ item.created }}</span>
</div>
<span class="alarm_incident" @click.stop="router.push(`/incidents/${item.incident_id}`)">
INC #{{ item.incident_id }}
</span>
</li>
</ul>
</div>
</div>
</template>
<style scoped>
.alarm_list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.alarm_groups { display: flex; flex-direction: column; gap: 1rem; }
.group_label {
font-size: 0.65rem;
letter-spacing: 0.1em;
color: var(--theme-dim);
margin-bottom: 0.4rem;
}
.group_count {
color: var(--theme);
margin-left: 0.5rem;
}
.alarm_item.assigned {
opacity: 0.6;
}
.alarm_incident {
font-size: 0.7rem;
color: var(--theme-dim);
white-space: nowrap;
transition: color 0.15s;
}
.alarm_incident:hover {
color: var(--theme);
}
.alarm_item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 1rem;
border-left: 4px solid transparent;
background: rgba(20, 20, 20, 0.9);
border-radius: 3px;
cursor: pointer;
transition: background 0.15s;
}
.alarm_item:hover {
background: rgba(40, 40, 40, 0.9);
}
.alarm_left {
display: flex;
flex-direction: column;
align-items: center;
min-width: 70px;
gap: 0.2rem;
}
.alarm_sev {
font-size: 0.7rem;
font-weight: bold;
letter-spacing: 0.05em;
}
.alarm_site {
font-size: 0.65rem;
color: rgba(255, 255, 255, 0.4);
}
.alarm_body {
display: flex;
flex-direction: column;
gap: 0.15rem;
flex: 1;
}
.alarm_text {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.85);
}
.alarm_time {
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.3);
}
/* severity colors */
.sev_1 { border-left-color: rgb(220, 30, 30); }
.sev_1 .alarm_sev { color: rgb(220, 30, 30); box-shadow: -4px 0 8px rgba(220, 30, 30, 0.4); }
.sev_1 { box-shadow: inset -1px 0 0 0 rgba(220,30,30,0.1); }
.sev_2 { border-left-color: rgb(220, 120, 30); }
.sev_2 .alarm_sev { color: rgb(220, 120, 30); }
.sev_3 { border-left-color: rgb(200, 180, 30); }
.sev_3 .alarm_sev { color: rgb(200, 180, 30); }
.sev_4 { border-left-color: rgb(60, 140, 200); }
.sev_4 .alarm_sev { color: rgb(60, 140, 200); }
.sev_5 { border-left-color: rgb(80, 160, 180); }
.sev_5 .alarm_sev { color: rgb(80, 160, 180); }
</style>

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
import { store } from '../store'
import { SEVERITY_COLORS } from '../main'
const props = defineProps<{
count: number
label: string
}>()
</script>
<template>
<div class="filter_row" :class="{ frozen: store.is_frozen, historical: store.is_historical && !store.is_frozen }">
<span class="alarm_count" :class="{ frozen: store.is_frozen, historical: store.is_historical && !store.is_frozen, live: store.is_live && !store.is_frozen }">
{{ count }} {{ label }}
</span>
<div class="severity_filters">
<button class="tab_btn" :class="{ active: store.severity_filter === null }" @click="store.severity_filter = null">ALL</button>
<button
v-for="n in [1,2,3,4,5]"
:key="n"
class="tab_btn sev_btn"
:class="{ active: store.severity_filter === n }"
:style="{ '--sev-color': SEVERITY_COLORS[n] }"
@click="store.severity_filter = n"
>{{ n }}</button>
</div>
</div>
</template>
<style scoped>
.filter_row {
position: fixed;
bottom: calc(10vh + 60px);
left: 50%;
transform: translateX(-50%);
width: min(1280px, 100vw - 4rem);
z-index: 1000;
background: rgba(10, 10, 10, 0.85);
padding: 0.5rem 1rem;
border: 1px solid rgba(255, 100, 0, 0.2);
border-radius: 6px;
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.filter_row.historical { border-color: rgba(200, 160, 100, 0.2); }
.filter_row.frozen { border-color: rgba(150, 220, 255, 0.2); }
.alarm_count {
font-size: 0.8rem;
/* color: rgba(255, 100, 0, 0.6); */
}
.alarm_count.live { color: rgba(255, 100, 0, 0.6); }
.alarm_count.historical { color: rgba(200, 160, 100, 0.6); }
.alarm_count.frozen { color: rgba(150, 220, 255, 0.6); }
.severity_filters {
display: flex;
gap: 0.3rem;
}
.sev_btn {
border-color: color-mix(in srgb, var(--sev-color) 50%, transparent);
color: color-mix(in srgb, var(--sev-color) 70%, transparent);
}
.sev_btn:hover {
border-color: var(--sev-color);
color: var(--sev-color);
}
.sev_btn.active {
background-color: color-mix(in srgb, var(--sev-color) 20%, transparent);
border-color: var(--sev-color);
color: var(--sev-color);
box-shadow: 0 0 8px color-mix(in srgb, var(--sev-color) 40%, transparent);
}
</style>

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { store } from '../store'
const router = useRouter()
defineProps<{
data: []
}>()
</script>
<template>
<div class="incident_list">
<div
v-for="item in data"
:key="item.id"
class="incident_item"
:class="`sev_${item.severity}`"
@click="router.push(`/incidents/${item.id}`)"
>
<div class="incident_header">
<span
class="incident_site"
@click.stop="router.push(`/cellsites/${item.site_id}`)"
>Site {{ item.site_id }}</span>
<span
class="incident_assignee"
@click.stop="() => { const r = store.robots.find(r => r.name === item.assigned_to); if (r) router.push(`/robots/${r.id}`) }"
>{{ item.assigned_to }}</span>
<span class="incident_status" :class="item.status">{{ item.status }}</span>
</div>
<div class="incident_text">{{ item.text }}</div>
<div class="incident_meta">{{ item.created }}</div>
<div class="incident_meta">{{ item.created_by }}</div>
</div>
<p v-if="data.length === 0">No tickets here... for now.</p>
</div>
</template>
<style scoped>
.incident_list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.incident_item {
background: rgba(20, 20, 20, 0.9);
border-left: 4px solid transparent;
border-radius: 3px;
padding: 0.6rem 1rem;
font-size: 0.85rem;
cursor: pointer;
transition: background 0.15s;
}
.incident_item:hover {
background: rgba(40, 40, 40, 0.9);
}
.incident_header {
display: flex;
gap: 1rem;
margin-bottom: 0.3rem;
}
.incident_site {
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
border: 1px solid transparent;
border-radius: 3px;
padding: 0 0.3rem;
}
.incident_site:hover {
color: var(--theme, rgba(255, 100, 0, 1));
text-decoration: underline;
text-underline-offset: 3px;
}
.incident_item:hover .incident_site {
border-color: var(--theme, rgba(255, 100, 0, 0.5));
color: var(--theme, rgba(255, 100, 0, 1));
}
.incident_status { margin-left: auto; font-size: 0.75rem; }
.incident_status.closed { color: rgba(100, 200, 100, 0.7); }
.incident_status.active { color: rgba(220, 30, 30, 0.9); }
.incident_text { color: rgba(255, 255, 255, 0.6); margin-bottom: 0.2rem; }
.incident_meta { font-size: 0.75rem; color: rgba(255, 255, 255, 0.3); }
.sev_1 { border-left-color: rgb(220, 30, 30); }
.sev_2 { border-left-color: rgb(220, 120, 30); }
.sev_3 { border-left-color: rgb(200, 180, 30); }
.sev_4 { border-left-color: rgb(60, 140, 200); }
.sev_5 { border-left-color: rgb(80, 160, 180); }
.incident_assignee {
color: rgba(255, 100, 0, 0.7);
cursor: pointer;
border: 1px solid transparent;
border-radius: 3px;
padding: 0 0.3rem;
transition: color 0.15s, border-color 0.15s;
}
.incident_assignee:hover {
color: rgba(255, 255, 255, 1);
text-decoration: underline;
text-underline-offset: 3px;
}
.incident_item:hover .incident_assignee {
border-color: rgba(255, 255, 255, 0.4);
color: rgba(255, 255, 255, 0.9);
}
</style>

View File

@@ -0,0 +1,392 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { store } from '../store'
import { useRouter } from 'vue-router'
import { SEVERITY_COLORS } from '../main'
import L from 'leaflet'
import 'leaflet.markercluster'
import 'leaflet.markercluster/dist/MarkerCluster.css'
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
const router = useRouter()
const props = defineProps<{
center?: [number, number],
zoom?: number,
site_id?: number,
alarms?: Record<string, any>[],
// TODO: Idk if passing map size as props is the best idea. Maybe there's a better way?
map_height?: string,
map_width?: string,
robots?: Record<string, any>[]
}>()
const center = props.center ?? [39.8283, -98.5795]
const zoom = props.zoom ?? 4
const show_alarm_layer = ref(true)
const show_robot_layer = ref(true)
const DEFAULT_COLOR = 'rgba(255, 255, 255, 1)'
const get_alarms_for_site = (site_id: number) =>
show_alarm_layer.value === false ? [] : (props.alarms ?? store.alarms).filter(a => a.site_id === site_id)
const get_marker_color = (site_id: number): string => {
const alarms = get_alarms_for_site(site_id)
if (alarms.length === 0) return DEFAULT_COLOR
const worst = Math.min(...alarms.map(a => a.severity))
return SEVERITY_COLORS[worst] ?? DEFAULT_COLOR
}
const get_site_worst_severity = (site_id: number): number | null => {
const alarms = get_alarms_for_site(site_id)
if (alarms.length === 0) return null
return Math.min(...alarms.map(a => a.severity))
}
const create_icon = (color: string) => L.divIcon({
className: '',
html: `<div class="map_marker" style="--marker-color: ${color}"><span></span></div>`,
iconSize: [28, 40],
iconAnchor: [14, 40],
popupAnchor: [0, -42]
})
const create_robot_icon = (on_call: boolean) => L.divIcon({
className: '',
html: `<div class="robot_marker ${on_call ? 'on_call' : 'idle'}"></div>`,
iconSize: [14, 14],
iconAnchor: [7, 7],
popupAnchor: [0, -10]
})
onMounted(() => {
const map = L.map('map').setView(center, zoom)
L.tileLayer(
'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
{
// attribution: '&copy; OpenStreetMap &copy; CARTO', // Ugly
subdomains: 'abcd',
maxZoom: 19
}
).addTo(map)
const markersLayer = L.markerClusterGroup({
polygonOptions: {
fillColor: 'rgba(255,255,255,0.1)',
color: 'rgba(255,255,255,0.8)',
weight: 2,
opacity: 0.8,
fillOpacity: 0.2
},
iconCreateFunction: (cluster) => {
const severities = cluster.getAllChildMarkers()
.map(m => (m.options as any).severity)
.filter(s => s !== null)
const worst = severities.length > 0 ? Math.min(...severities) : null
const color = worst !== null ? SEVERITY_COLORS[worst] : 'rgba(255,255,255,0.8)'
return L.divIcon({
html: `<div class="cluster_icon" style="--cluster-color: ${color}">
<span>${cluster.getChildCount()}</span>
</div>`,
className: '',
iconSize: [40, 40]
})
}
}).addTo(map)
const robotsLayer = L.layerGroup().addTo(map)
const update_robot_markers = () => {
robotsLayer.clearLayers()
if (!props.robots || !show_robot_layer.value) return
props.robots.forEach(robot => {
if (robot.lat == null || robot.lon == null) return
L.marker([robot.lat, robot.lon], {
icon: create_robot_icon(!!robot.current_incident_id)
})
.addTo(robotsLayer)
// TODO: Maybe make the entire popup clickable instead of the text itself? Looks odd like this.
.bindPopup(`
<a href="#" data-id="${robot.id}" class="robot-link">${robot.name.toUpperCase()}</a><br/>
${robot.current_incident_id ? `INC #${robot.current_incident_id}` : 'IDLE'}<br/>
${robot.current_site_id ? `SITE ${robot.current_site_id}` : ''}
`)
})
}
const update_markers = () => {
markersLayer.clearLayers()
const bounds = map.getBounds()
const sites = props.site_id != null
? store.cellsites.filter(s => s.id === props.site_id)
: store.cellsites
sites.forEach(site => {
if (site.lat != null && site.lon != null) {
const latLng = L.latLng(site.lat, site.lon)
if (bounds.contains(latLng)) {
const marker = L.marker([site.lat, site.lon], {
icon: create_icon(get_marker_color(site.id)),
severity: get_site_worst_severity(site.id)
})
marker.addTo(markersLayer)
.bindPopup(`<a href="#" data-id="${site.id}" class="marker-link">Site ${site.id}</a>`)
}
}
})
}
map.on('popupopen', (e) => {
const el = e.popup.getElement()
const site_link = el.querySelector('.marker-link')
site_link?.addEventListener('click', (event) => {
event.preventDefault()
router.push(`/cellsites/${site_link.dataset.id}`)
}, { once: true }) // NOTE: To avoid accumulating listeners
const robot_link = el.querySelector('.robot-link')
robot_link?.addEventListener('click', (event) => {
event.preventDefault()
router.push(`/robots/${robot_link.dataset.id}`)
}, { once: true }) // NOTE: To avoid accumulating listeners
})
map.whenReady(() => {
update_markers()
update_robot_markers()
map.on('moveend', update_markers)
map.on('moveend', update_robot_markers)
})
watch(() => store.cellsites, () => update_markers(), { deep: true })
watch(() => props.alarms, () => update_markers(), { deep: true })
watch(show_alarm_layer, () => update_markers())
watch(() => props.robots, () => update_robot_markers(), { deep: true })
watch(show_robot_layer, () => update_robot_markers())
watch(() => props.center, (newCenter) => {
if (newCenter) map.setView(newCenter, map.getZoom())
})
})
</script>
<template>
<div style="position: relative;">
<div class="map_wrapper">
<div class="layer_panel">
<div class="layer_title">LAYERS</div>
<label class="layer_item">
<input type="checkbox" v-model="show_alarm_layer" />
<span>Alarms</span>
</label>
<label class="layer_item">
<input type="checkbox" v-model="show_robot_layer" />
<span>Robots</span>
</label>
</div>
<div id="map" :style="{ height: props.map_height ?? '500px', width: props.map_width ?? '100%' }"></div>
</div>
</div>
</template>
<!-- NOTE: style must be global for the leaflet map to pick it up (reminder: vue adds data attribute to the scoped styles) -->
<style >
.leaflet-container {
font-family: 'Departure Mono', monospace !important;
}
.marker-cluster-small,
.marker-cluster-medium,
.marker-cluster-large {
background-color: rgba(255,255,255,0.2)
}
.marker-cluster-small div,
.marker-cluster-medium div,
.marker-cluster-large div {
background-color: rgba(255,255,255,0.8);
color: #000;
}
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
background-color: rgba(20, 20, 20, 1) !important;
color: var(--theme, rgba(255, 100, 0, 1)) !important;
border: 1px solid var(--theme-faint, rgba(255, 100, 0, 0.4)) !important;
font-family: 'Departure Mono', monospace !important;
transition: all 0.2s;
}
.leaflet-control-zoom-in:hover,
.leaflet-control-zoom-out:hover {
background-color: var(--theme-bg, rgba(255, 100, 0, 0.15)) !important;
color: var(--theme, rgba(255, 100, 0, 1)) !important;
border-color: var(--theme-dim, rgba(255, 100, 0, 0.8)) !important;
}
.leaflet-control-zoom {
border: 1px solid var(--theme-faint, rgba(255, 100, 0, 0.4)) !important;
border-radius: 4px !important;
overflow: hidden;
}
.layer_panel {
position: absolute;
top: 1rem;
right: 1rem;
z-index: 1000;
background: rgba(20, 20, 20, 1);
border: 1px solid var(--theme-faint, rgba(255, 100, 0, 0.4));
border-radius: 6px;
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
backdrop-filter: blur(4px);
}
.layer_title {
color: var(--theme-dim, rgba(255, 100, 0, 0.6));
margin-bottom: 0.4rem;
letter-spacing: 0.05em;
}
.layer_item {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--theme, rgba(255, 100, 0, 1));
cursor: pointer;
}
.layer_item input[type="checkbox"] {
accent-color: var(--theme, rgba(255, 100, 0, 1));
}
.map_marker {
position: relative;
width: 28px;
height: 40px;
}
/* tower body */
.map_marker::before {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 3px;
height: 28px;
background-color: var(--marker-color);
}
/* signal arc outer */
.map_marker::after {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 24px;
height: 16px;
border-radius: 24px 24px 0 0;
border: 3px solid var(--marker-color);
border-bottom: none;
box-shadow: 0 0 10px rgba(255, 255, 255, 0.8);
}
.map_marker span {
position: absolute;
top: 6px;
left: 50%;
transform: translateX(-50%);
width: 12px;
height: 8px;
border-radius: 12px 12px 0 0;
border: 3px solid rgba(255, 255, 255, 0.6);
border-bottom: none;
}
.map_wrapper {
position: relative;
}
.map_wrapper::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
z-index: 400;
background: transparent;
transition: background 0.4s;
}
.robot_marker {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.9);
background: rgba(40, 40, 40, 0.9);
box-shadow: 0 0 6px rgba(255, 255, 255, 0.4);
}
.robot_marker.on_call {
border-color: var(--theme, rgba(255, 100, 0, 1));
background: rgba(255, 100, 0, 0.2);
box-shadow: 0 0 8px rgba(255, 100, 0, 0.6);
}
.mode_historical .map_wrapper::after {
background: rgba(200, 160, 100, 0.06);
}
.mode_frozen .map_wrapper::after {
background: rgba(150, 220, 255, 0.06);
}
.leaflet-popup-content-wrapper {
background-color: #2a2a2a;
color: rgba(255, 100, 0, 1);
border: 1px solid rgba(255, 100, 0, 0.4);
box-shadow: 0 0 10px rgba(255, 100, 0, 0.2);
}
.leaflet-popup-tip {
background-color: #2a2a2a;
}
.leaflet-popup-content a {
color: rgba(255, 100, 0, 1);
}
.leaflet-control-attribution {
display: none;
}
.cluster_icon {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: color-mix(in srgb, var(--cluster-color) 30%, transparent);
border: 2px solid var(--cluster-color);
display: flex;
align-items: center;
justify-content: center;
}
.cluster_icon span {
color: #000;
/* NOTE: Need the font here */
font-family: 'Departure Mono', monospace;
font-size: 0.75rem;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
import SimulationBanner from './SimulationBanner.vue'
const searchQuery = ref('')
const popupOpen = ref(false)
// TODO: Add search for site w/ modal popup
const togglePopup = () => {
popupOpen.value = !popupOpen.value
}
</script>
<template>
<SimulationBanner />
<nav>
<ul>
<li class="nav-back" :style="{ visibility: route.path !== '/' ? 'visible' : 'hidden' }">
<button @click="router.back()"> back</button>
</li>
<li class="nav-center">
<a href="/">three60</a>
</li>
</ul>
</nav>
</template>
<style scoped>
nav {
background: #333;
color: #fff;
padding: 0.5rem 1rem;
margin: 0 0 1rem 0;
}
nav ul {
display: flex;
align-items: center;
list-style: none;
margin: 0;
padding: 0;
}
.nav-back {
flex: 1;
}
nav ul::after {
content: '';
flex: 1;
}
.nav-center a {
color: var(--theme);
text-decoration: none;
transition: color 0.3s ease;
}
.nav-back button {
background: transparent;
border: none;
color: var(--theme-dim, rgba(255, 100, 0, 0.6));
font-size: 0.8rem;
cursor: pointer;
padding: 0;
transition: color 0.2s;
}
.nav-back button:hover {
color: var(--theme, rgba(255, 100, 0, 1));
}
</style>

View File

@@ -0,0 +1,116 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { get_request } from '../ajax_requests'
import { store } from '../store'
const stats = ref(null)
const countdown_alarm = ref(0)
const countdown_incident = ref(0)
let poll_interval: ReturnType<typeof setInterval> | null = null
let tick_interval: ReturnType<typeof setInterval> | null = null
// FIXME: I don't know if i need this type of check in two spots? I also check in the timeline intervals.
// Possibly worth having in case I add a "non simulation mode"?
const fetch_status = async () => {
try {
const data = await get_request('/api/v1/simulator/status')
stats.value = data
countdown_alarm.value = Math.max(0, Math.round(data.next_alarm_cleanup_at - Date.now() / 1000))
countdown_incident.value = Math.max(0, Math.round(data.next_incident_cleanup_at - Date.now() / 1000))
if (store.server_unreachable) {
store.server_unreachable = false
store.unfreeze()
}
} catch (e) {
console.error('SimulationBanner fetch failed:', e)
store.server_unreachable = true
store.freeze()
if (poll_interval) { clearInterval(poll_interval); poll_interval = null }
if (tick_interval) { clearInterval(tick_interval); tick_interval = null }
if (!poll_interval) {
poll_interval = setInterval(fetch_status, 10000)
}
}
}
const fmt = (s: number) => {
const m = Math.floor(s / 60)
const sec = s % 60
return m > 0 ? `${m}m ${sec}s` : `${sec}s`
}
onMounted(async () => {
await fetch_status()
poll_interval = setInterval(fetch_status, 30000)
tick_interval = setInterval(() => {
if (countdown_alarm.value > 0) countdown_alarm.value--
if (countdown_incident.value > 0) countdown_incident.value--
if (countdown_alarm.value === 0 || countdown_incident.value === 0) fetch_status()
}, 1000)
})
onUnmounted(() => {
if (poll_interval) clearInterval(poll_interval)
if (tick_interval) clearInterval(tick_interval)
})
</script>
<template>
<div v-if="store.server_unreachable" class="unreachable_banner">
SERVER UNREACHABLE polling paused
</div>
<div class="sim_banner" v-if="stats">
<span class="sim_dot"></span>
<span class="sim_label">SIMULATION ACTIVE</span>
<span class="sim_stat">{{ stats.alarm_count }} alarms</span>
<span class="sim_divider">|</span>
<span class="sim_stat">{{ stats.incident_count }} incidents</span>
<span class="sim_divider">|</span>
<span class="sim_stat">alarm reset in <span class="sim_countdown">{{ fmt(countdown_alarm) }}</span></span>
<span class="sim_divider">|</span>
<span class="sim_stat">incident reset in <span class="sim_countdown">{{ fmt(countdown_incident) }}</span></span>
</div>
</template>
<style scoped>
.sim_banner {
width: 100%;
background: rgba(10, 10, 10, 0.9);
border-bottom: 1px solid rgba(255, 100, 0, 0.15);
padding: 0.3rem 2rem;
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.72rem;
color: rgba(255, 255, 255, 0.35);
margin-bottom: 1rem;
}
.sim_dot {
color: rgba(100, 220, 100, 0.8);
font-size: 0.6rem;
}
.sim_label {
color: rgba(100, 220, 100, 0.6);
letter-spacing: 0.08em;
}
.sim_divider {
opacity: 0.2;
}
.sim_countdown {
color: rgba(255, 255, 255, 0.6);
}
.unreachable_banner {
width: 100%;
background: rgba(220, 30, 30, 0.15);
border-bottom: 1px solid rgba(220, 30, 30, 0.4);
padding: 0.3rem 2rem;
font-size: 0.72rem;
color: rgba(220, 30, 30, 0.9);
text-align: center;
}
</style>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
defineProps<{
data: []
link_prefix?: string
}>()
</script>
<template>
<div class="table_wrapper">
<table v-if="data.length > 0">
<thead>
<tr>
<th v-for="key in Object.keys(data[0])" :key="key">{{ key }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in data" :key="index">
<td v-for="key in Object.keys(data[0])" :key="key">
<a
v-if="key === 'id' && link_prefix"
href="#"
@click.prevent="router.push(`${link_prefix}/${row[key]}`)"
>
{{ row[key] }}
</a>
<span v-else>{{ row[key] }}</span>
</td>
</tr>
</tbody>
</table>
<p v-else>No data.</p>
</div>
</template>
<style scoped>
.table_wrapper {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
min-width: 600px;
}
thead {
border-bottom: 2px solid var(--theme-dim);
}
th {
text-align: left;
padding: 0.75rem 1rem;
color: var(--theme);
font-weight: normal;
white-space: nowrap;
}
td {
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--theme-faint);
white-space: nowrap;
}
tbody tr:hover {
background-color: var(--theme-bg);
}
a {
color: var(--theme);
text-decoration: none;
}
a:hover {
text-decoration: underline;
color: white;
}
@media (max-width: 768px) {
th, td {
padding: 0.5rem 0.6rem;
font-size: 0.8rem;
}
}
</style>

View File

@@ -0,0 +1,205 @@
<script setup lang="ts">
import { store } from '../store'
const emit = defineEmits<{
refresh: []
freeze: []
snap_to_live: []
}>()
</script>
<template>
<div class="timeline_row" :class="{ frozen: store.is_frozen, historical: store.is_historical && !store.is_frozen }">
<!-- NOTE: @input="store.timeline_position = Number(($event.target as HTMLInputElement).value)" is used for timeline position scrolling + link w/ map and data filters, it fires after a brief debounce -->
<input
type="range"
class="timeline_slider"
:min="store.timeline_min"
:max="store.timeline_max"
:value="store.timeline_position"
@input="store.timeline_position = Number(($event.target as HTMLInputElement).value)"
@change="store.is_live ? store.timeline_position = store.timeline_max : null"
:disabled="store.is_frozen"
:style="{ '--slider-color': store.is_frozen ? 'rgba(150, 220, 255, 1)' : store.is_historical ? 'rgba(200, 160, 100, 1)' : 'rgba(255, 100, 0, 1)' }"
/>
<div class="interval_select" :class="{ frozen: store.is_frozen, historical: store.is_historical && !store.is_frozen }">
<button class="tab_btn" :class="{ active: store.refresh_interval_seconds === 5 }" :disabled="store.is_frozen || store.is_historical" @click="store.refresh_interval_seconds = 5">5s</button>
<button class="tab_btn" :class="{ active: store.refresh_interval_seconds === 30 }" :disabled="store.is_frozen || store.is_historical" @click="store.refresh_interval_seconds = 30">30s</button>
<button class="tab_btn" :class="{ active: store.refresh_interval_seconds === 60 }" :disabled="store.is_frozen || store.is_historical" @click="store.refresh_interval_seconds = 60">60s</button>
</div>
<span class="timeline_label"
:class="{ live: store.is_live && !store.is_frozen, frozen: store.is_frozen, historical: store.is_historical && !store.is_frozen }"
@click="store.is_frozen ? emit('snap_to_live') : store.is_live ? emit('freeze') : emit('snap_to_live')"
>
{{ store.is_frozen ? 'FROZEN' : store.is_live ? `LIVE ${store.live_countdown}s` : new Date(store.timeline_position).toLocaleString() }}
<span v-if="store.is_frozen && store.frozen_at" class="frozen_since">
since {{ new Date(store.frozen_at).toLocaleTimeString() }}
</span>
<span class="live_hint">{{ store.is_frozen ? 'LIVE' : store.is_live ? 'FREEZE' : 'LIVE' }}</span>
</span>
<button class="tab_btn refresh_btn" @click="emit('refresh')"></button>
</div>
</template>
<style scoped>
.timeline_row {
position: fixed;
bottom: 10vh;
left: 50%;
transform: translateX(-50%);
width: min(1280px, 100vw - 4rem);
z-index: 1000;
background: rgba(10, 10, 10, 0.85);
padding: 0.6rem 1rem;
border: 1px solid rgba(255, 100, 0, 0.2);
border-radius: 6px;
backdrop-filter: blur(4px);
display: flex;
align-items: center;
gap: 1rem;
}
.frozen_since {
display: block;
font-size: 0.65rem;
opacity: 0.6;
margin-top: 0.1rem;
}
.timeline_row.historical { border-color: rgba(200, 160, 100, 0.2); }
.timeline_row.frozen { border-color: rgba(150, 220, 255, 0.2); }
.timeline_slider {
flex: 1;
accent-color: var(--slider-color, rgba(255, 100, 0, 1));
cursor: pointer;
}
.timeline_slider:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.interval_select {
display: flex;
gap: 0.3rem;
}
.interval_select.frozen .tab_btn,
.interval_select.historical .tab_btn {
cursor: not-allowed;
pointer-events: none;
}
.interval_select.frozen .tab_btn {
border-color: rgba(150, 220, 255, 0.3);
color: rgba(150, 220, 255, 0.4);
}
.interval_select.frozen .tab_btn.active {
background-color: rgba(150, 220, 255, 0.08);
border-color: rgba(150, 220, 255, 0.5);
color: rgba(150, 220, 255, 0.6);
box-shadow: 0 0 6px rgba(150, 220, 255, 0.2);
}
.interval_select.historical .tab_btn {
border-color: rgba(200, 160, 100, 0.3);
color: rgba(200, 160, 100, 0.4);
}
.interval_select.historical .tab_btn.active {
background-color: rgba(200, 160, 100, 0.08);
border-color: rgba(200, 160, 100, 0.5);
color: rgba(200, 160, 100, 0.6);
box-shadow: 0 0 6px rgba(200, 160, 100, 0.2);
}
.timeline_label {
font-size: 0.8rem;
color: rgba(255, 100, 0, 0.6);
white-space: nowrap;
min-width: 160px;
min-height: 2.4rem;
text-align: center;
position: relative;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: color 0.2s ease;
}
.timeline_label.live {
color: rgba(255, 100, 0, 1);
text-shadow: 0 0 6px rgba(255, 100, 0, 0.6);
}
.timeline_label.frozen {
color: rgba(150, 220, 255, 1);
text-shadow: 0 0 8px rgba(150, 220, 255, 0.7), 0 0 20px rgba(100, 180, 255, 0.3);
}
.timeline_label.historical {
color: rgba(200, 160, 100, 0.8);
}
.live_hint {
opacity: 0;
pointer-events: none;
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.2s ease;
}
.timeline_label:not(.live):not(.frozen):hover .live_hint {
opacity: 1;
color: rgba(255, 255, 255, 0.25);
}
.timeline_label:not(.live):not(.frozen):hover {
display: flex;
color: transparent;
}
.timeline_label.live:hover .live_hint {
opacity: 1;
color: rgba(150, 220, 255, 0.6);
}
.timeline_label.live:hover {
color: transparent;
text-shadow: none;
}
.timeline_label.frozen:hover .live_hint {
opacity: 1;
color: rgba(255, 100, 0, 0.8);
}
.timeline_label.frozen:hover {
color: transparent;
text-shadow: none;
}
.refresh_btn {
padding: 0.4rem 0.6rem;
font-size: 1rem;
line-height: 1;
}
.refresh_btn:hover {
color: rgba(255, 100, 0, 1);
border-color: rgba(255, 100, 0, 1);
box-shadow: 0 0 6px rgba(255, 100, 0, 0.4);
}
</style>

View File

@@ -0,0 +1,39 @@
import { computed } from 'vue'
import type { Ref } from 'vue'
import { store } from '../store'
// Common filters, refactored for reuse.
export function use_filtered_data(
is_live_debounced: Ref<boolean>,
timeline_position_debounced: Ref<number>
) {
const visible_alarms = computed(() => {
if (is_live_debounced.value) return store.alarms
const cutoff = timeline_position_debounced.value / 1000
return store.alarms.filter(a => new Date(a.created + ' UTC').getTime() / 1000 <= cutoff)
})
const visible_incidents = computed(() => {
if (is_live_debounced.value) return store.incidents
const cutoff = timeline_position_debounced.value / 1000
return store.incidents.filter(a => new Date(a.created + ' UTC').getTime() / 1000 <= cutoff)
})
const filtered_alarms = computed(() => {
const sorted = [...visible_alarms.value].sort((a, b) =>
new Date(b.created + ' UTC').getTime() - new Date(a.created + ' UTC').getTime()
)
if (store.severity_filter === null) return sorted
return sorted.filter(a => a.severity === store.severity_filter)
})
const filtered_incidents = computed(() => {
const sorted = [...visible_incidents.value].sort((a, b) =>
new Date(b.created + ' UTC').getTime() - new Date(a.created + ' UTC').getTime()
)
if (store.severity_filter === null) return sorted
return sorted.filter(a => a.severity === store.severity_filter)
})
return { visible_alarms, visible_incidents, filtered_alarms, filtered_incidents }
}

View File

@@ -0,0 +1,107 @@
import { ref, computed, watch, onUnmounted } from 'vue'
import { nextTick } from 'vue'
import { store } from '../store'
export function use_timeline(fetch_fn: (before?: number) => Promise<void>) {
let live_interval: ReturnType<typeof setInterval> | null = null
let countdown_interval: ReturnType<typeof setInterval> | null = null
let debounce_timer: ReturnType<typeof setTimeout> | null = null
const MAX_FAILURES = 3
const failure_count = ref(0)
const timeline_position_debounced = ref(store.timeline_position)
const is_live_debounced = computed(() =>
timeline_position_debounced.value >= store.timeline_max - 2000
)
const manual_refresh = async () => {
store.unfreeze()
await fetch_fn()
await nextTick()
store.timeline_position = store.timeline_max
if (live_interval) clearInterval(live_interval)
if (countdown_interval) clearInterval(countdown_interval)
store.live_countdown = store.refresh_interval_seconds
live_interval = setInterval(async () => {
await safe_fetch()
store.live_countdown = store.refresh_interval_seconds
}, store.refresh_interval_seconds * 1000)
countdown_interval = setInterval(() => {
if (store.live_countdown > 0) store.live_countdown--
}, 1000)
}
const freeze = () => store.freeze()
const snap_to_live = async () => {
store.unfreeze()
await fetch_fn()
await nextTick()
store.timeline_position = store.timeline_max
}
// NOTE: This is in place to avoid the intervals breaking on server issues and constantly polling the backend.
const safe_fetch = async () => {
try {
await fetch_fn()
failure_count.value = 0
} catch (e) {
failure_count.value++
console.error(`Fetch failed (${failure_count.value}/${MAX_FAILURES})`, e)
if (failure_count.value >= MAX_FAILURES) {
store.freeze()
store.server_unreachable = true
if (live_interval) { clearInterval(live_interval); live_interval = null }
if (countdown_interval) { clearInterval(countdown_interval); countdown_interval = null }
}
}
}
watch(() => store.is_live, (live) => {
if (live && !store.is_frozen) {
store.live_countdown = store.refresh_interval_seconds
live_interval = setInterval(async () => {
await safe_fetch()
store.live_countdown = store.refresh_interval_seconds
}, store.refresh_interval_seconds * 1000)
countdown_interval = setInterval(() => {
if (store.live_countdown > 0) store.live_countdown--
}, 1000)
} else {
if (live_interval) clearInterval(live_interval)
if (countdown_interval) clearInterval(countdown_interval)
store.live_countdown = store.refresh_interval_seconds
}
}, { immediate: true })
watch(() => store.refresh_interval_seconds, () => {
if (store.is_live) {
if (live_interval) clearInterval(live_interval)
if (countdown_interval) clearInterval(countdown_interval)
store.live_countdown = store.refresh_interval_seconds
live_interval = setInterval(async () => {
await safe_fetch()
store.live_countdown = store.refresh_interval_seconds
}, store.refresh_interval_seconds * 1000)
countdown_interval = setInterval(() => {
if (store.live_countdown > 0) store.live_countdown--
}, 1000)
}
})
watch(() => store.timeline_position, (val) => {
if (debounce_timer) clearTimeout(debounce_timer)
debounce_timer = setTimeout(() => {
timeline_position_debounced.value = val
}, 150)
})
onUnmounted(() => {
if (live_interval) clearInterval(live_interval)
if (countdown_interval) clearInterval(countdown_interval)
if (debounce_timer) clearTimeout(debounce_timer)
})
return { timeline_position_debounced, is_live_debounced, manual_refresh, freeze, snap_to_live }
}

59
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,59 @@
import './assets/normalize.css'
import './assets/main.css'
import 'leaflet/dist/leaflet.css'
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
export const BASE_URL = '/api/v1'
export const ALARMS_ENDPOINT = `${BASE_URL}/alarms`
export const ROBOTS_ENDPOINT = `${BASE_URL}/robots`
export const CELLSITES_ENDPOINT = `${BASE_URL}/cellsites`
export const INCIDENTS_ENDPOINT = `${BASE_URL}/incidents`
export const SEVERITY_COLORS: Record<number, string> = {
1: 'rgb(220, 30, 30)',
2: 'rgb(220, 120, 30)',
3: 'rgb(200, 180, 30)',
4: 'rgb(60, 140, 200)',
5: 'rgb(80, 160, 180)',
}
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: () => import('./pages/Home.vue'),
// meta: { requiresAuth: true }
},
// {
// path: '/login',
// // component: () => import ('./pages/Login.vue')
// },
{
path: '/cellsites/:id',
component: () => import('./pages/Cellsite.vue')
// meta: { requiresAuth: true }
},
{ path: '/incidents/:id',
component: () => import('./pages/Incident.vue')
},
{ path: '/robots/:id',
component: () => import('./pages/Robots.vue')
}
]
})
// FIXME: Disabling for demo, not hoooked up anyway
// router.beforeEach((to, from, next) => {
// const isAuthenticated = !!localStorage.getItem('token')
// if (to.meta.requiresAuth && !isAuthenticated) {
// next('/login')
// } else {
// next()
// }
// })
import App from './App.vue'
createApp(App).use(router).mount('#app')

View File

@@ -0,0 +1,248 @@
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { store } from '../store'
import { get_request } from '../ajax_requests'
import { ALARMS_ENDPOINT, CELLSITES_ENDPOINT, INCIDENTS_ENDPOINT, ROBOTS_ENDPOINT } from '../main'
import Nav from '../components/Nav.vue'
import Map from '../components/Map.vue'
import AlarmsCard from '../components/AlarmsCard.vue'
import IncidentCard from '../components/IncidentCard.vue'
import { use_timeline } from '../composables/use_timeline'
import { use_filtered_data } from '../composables/use_filtered_data'
import TimelineRow from '../components/TimelineRow.vue'
import FilterRow from '../components/FilterRow.vue'
const active_tab = computed({
get: () => (store.detail_tabs[`cellsite_${id}`] ?? 'alarms') as 'alarms' | 'incidents',
set: (val) => { store.detail_tabs[`cellsite_${id}`] = val }
})
const route = useRoute()
const loading = ref(true)
const id = Number(route.params.id)
// FIXME: Hardcoding stat fields for now. They probably won't change from opencellid
const stats_fields = computed(() => [
['RADIO', site.value?.radio],
['MCC', site.value?.mcc],
['NET', site.value?.net],
['AREA', site.value?.area],
['CELL', site.value?.cell],
['UNIT', site.value?.unit],
['LAT', site.value?.lat],
['LON', site.value?.lon],
['RANGE', site.value?.range],
['SAMPLES', site.value?.samples],
['SIGNAL', site.value?.averageSignal],
['CREATED', site.value?.created],
['UPDATED', site.value?.updated],
])
const site = computed(() =>
store.cellsites.find(s => s.id === id)
)
const fetch_all = async (before?: number) => {
const was_live = store.is_live
const alarm_url = before ? `${ALARMS_ENDPOINT}?id=${id}&before=${before}` : `${ALARMS_ENDPOINT}?id=${id}`
const incident_url = before ? `${INCIDENTS_ENDPOINT}?id=${id}&before=${before}` : `${INCIDENTS_ENDPOINT}?id=${id}`
const [alarms, incidents, robots] = await Promise.all([
get_request(alarm_url),
get_request(incident_url),
get_request(ROBOTS_ENDPOINT),
])
store.set_alarms(alarms)
store.set_incidents(incidents)
store.set_robots(robots)
if (before === undefined && !store.is_frozen && was_live) {
const now = Date.now()
store.timeline_max = now
store.timeline_position = now
} else {
store.timeline_max = Date.now()
}
}
const { timeline_position_debounced, is_live_debounced, manual_refresh, freeze, snap_to_live } = use_timeline(fetch_all)
const { visible_alarms, visible_incidents, filtered_alarms, filtered_incidents } = use_filtered_data(is_live_debounced, timeline_position_debounced)
onMounted(async () => {
console.log(store.is_live)
const site_exists = store.cellsites.some(s => s.id === id)
if (!site_exists) {
const data = await get_request(`${CELLSITES_ENDPOINT}/${id}`)
store.set_cellsites(data)
}
// Incase you refresh or nav to the page directly.
try {
await fetch_all()
const all = [...store.alarms, ...store.incidents]
if (all.length > 0) {
const oldest = Math.min(...all.map(r => new Date(r.created + ' UTC').getTime()))
store.timeline_min = store.timeline_min === 0 ? oldest : Math.min(store.timeline_min, oldest)
} else if (store.timeline_min === 0) {
store.timeline_min = Date.now() - 86400000
}
} catch (error) {
console.error('Error fetching data:', error)
} finally {
loading.value = false
}
})
</script>
<template>
<div :class="{ mode_historical: store.is_historical && !store.is_frozen, mode_frozen: store.is_frozen }">
<Nav />
<div class="site_container">
<div v-if="loading" class="loading-overlay">
<p>Loading...</p>
</div>
<div v-else-if="!site" class="not-found">
<p>Not found</p>
</div>
<div v-else class="site_content">
<div class="stats">
<div class="site_id">SITE {{ site.id }}</div>
<div class="stat_row" v-for="[key, val] in stats_fields" :key="key">
<span class="stat_key">{{ key }}</span>
<span class="stat_val">{{ val }}</span>
</div>
</div>
<!-- FIXME: Map container leftover from previous version. Map can take height props. Refactor this. -->
<div class="map_container">
<Map
v-if="site.lat != null && site.lon != null"
:center="[site.lat, site.lon]"
:zoom="12"
:site_id="site.id"
:robots="store.robots"
/>
</div>
<div class="data">
<div class="tabs">
<button class="tab_btn" :class="{ active: active_tab === 'alarms' }" @click="active_tab = 'alarms'">
Alarms <span class="tab_count">{{ filtered_alarms.length }}</span>
</button>
<button class="tab_btn" :class="{ active: active_tab === 'incidents' }" @click="active_tab = 'incidents'">
Incidents <span class="tab_count">{{ filtered_incidents.length }}</span>
</button>
</div>
<div class="data_scroll">
<AlarmsCard v-if="active_tab === 'alarms' && filtered_alarms.length > 0" :data="filtered_alarms" />
<p v-else-if="active_tab === 'alarms'">All quiet here... too quiet...</p>
<IncidentCard v-if="active_tab === 'incidents'" :data="filtered_incidents" />
</div>
</div>
</div>
<FilterRow
:count="active_tab === 'incidents' ? filtered_incidents.length : filtered_alarms.length"
:label="active_tab === 'incidents' ? 'tickets' : 'alarms'"
/>
<TimelineRow @refresh="manual_refresh" @freeze="freeze" @snap_to_live="snap_to_live" />
</div>
</div>
</template>
<style scoped>
.site_container {
position: relative;
padding: 1rem;
}
.site_content {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto;
grid-template-areas:
"stats map"
"data data";
gap: 1rem;
align-items: start;
}
.site_id {
font-size: 1.4rem;
color: var(--theme, rgba(255, 100, 0, 1));
margin-bottom: 1rem;
letter-spacing: 0.05em;
}
.stat_row {
display: flex;
justify-content: space-between;
padding: 0.3rem 0;
border-bottom: 1px solid var(--theme-faint);
font-size: 0.8rem;
}
.stat_key {
color: var(--theme-dim);
}
.stat_val {
color: rgba(255, 255, 255, 0.8);
}
.stats {
grid-area: stats;
}
.map_container {
grid-area: map;
min-height: 400px;
}
.data {
grid-area: data;
margin-top: 1rem;
}
.tabs {
margin-bottom: 1rem;
}
.loading-overlay,
.not-found {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: rgba(10, 10, 10, 0.95);
z-index: 10;
font-size: 1.5rem;
text-align: center;
color: rgba(255, 100, 0, 0.8);
}
@media (max-width: 768px) {
.site_content {
grid-template-columns: 1fr;
grid-template-areas:
"stats"
"map"
"data";
}
.map_container {
height: 300px;
}
}
</style>

193
frontend/src/pages/Home.vue Normal file
View File

@@ -0,0 +1,193 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { store } from '../store'
import { get_request } from '../ajax_requests'
import { ALARMS_ENDPOINT, CELLSITES_ENDPOINT, INCIDENTS_ENDPOINT, ROBOTS_ENDPOINT } from '../main'
import Nav from '../components/Nav.vue'
import Map from '../components/Map.vue'
import Table from '../components/Table.vue'
import AlarmsCard from '../components/AlarmsCard.vue'
import IncidentCard from '../components/IncidentCard.vue'
import { use_timeline } from '../composables/use_timeline'
import { use_filtered_data } from '../composables/use_filtered_data'
import TimelineRow from '../components/TimelineRow.vue'
import FilterRow from '../components/FilterRow.vue'
// TODO: Add search, sort, filter on alarms + sites
const loading = ref(true)
const active_tab = computed({
get: () => (store.detail_tabs['home'] ?? 'map') as 'map' | 'alarms' | 'incidents' | 'sites' | 'robots',
set: (val) => { store.detail_tabs['home'] = val }
})
// const severity_filter = ref<number | null>(null) // NOTE: Null here means show all severities.
const status_filter = ref<string | null>(null)
const fetch_all = async (before?: number) => {
const was_live = store.is_live
const alarm_url = before ? `${ALARMS_ENDPOINT}?before=${before}` : `${ALARMS_ENDPOINT}`
const incident_url = before ? `${INCIDENTS_ENDPOINT}?before=${before}` : `${INCIDENTS_ENDPOINT}`
const robot_url = `${ROBOTS_ENDPOINT}`
const [alarms, incidents, robots] = await Promise.all([
get_request(alarm_url),
get_request(incident_url),
get_request(robot_url),
])
store.set_alarms(alarms)
store.set_incidents(incidents)
store.set_robots(robots)
if (before === undefined && !store.is_frozen && was_live) {
const now = Date.now()
store.timeline_max = now
store.timeline_position = now
} else {
store.timeline_max = Date.now()
}
}
const { timeline_position_debounced, is_live_debounced, manual_refresh, freeze, snap_to_live } = use_timeline(fetch_all)
const { visible_alarms, visible_incidents, filtered_alarms, filtered_incidents } = use_filtered_data(is_live_debounced, timeline_position_debounced)
onMounted(async () => {
// store.unfreeze() // TODO: EXPERIMENT: My current idea is that FREEZING should freeze the entire app. So a user can investigate at a point in time throughout. A full page reload will reset this.
try {
const [sites, robots] = await Promise.all([
get_request(`${CELLSITES_ENDPOINT}`),
get_request(`${ROBOTS_ENDPOINT}`),
])
store.set_cellsites(sites)
store.set_robots(robots)
await fetch_all()
const all = [...store.alarms, ...store.incidents]
if (all.length > 0) {
const oldest = Math.min(...all.map(r => new Date(r.created + ' UTC').getTime()))
store.timeline_min = store.timeline_min === 0
? oldest
: Math.min(store.timeline_min, oldest)
} else if (store.timeline_min === 0) {
store.timeline_min = Date.now() - 86400000
}
} catch (error) {
console.error('Error on initial load:', error)
} finally {
loading.value = false
}
})
</script>
<template>
<div :class="{ mode_historical: store.is_historical && !store.is_frozen, mode_frozen: store.is_frozen }">
<Nav />
<div v-if="loading">
<p>loading...</p>
</div>
<div v-else>
<div class="tabs">
<button
@click="active_tab = 'map'"
:class="{ active: active_tab === 'map' }"
class="tab_btn"
>Map
</button>
<button
@click="active_tab = 'alarms'"
:class="{ active: active_tab === 'alarms' }"
class="tab_btn"
>Alarms
</button>
<button
@click="active_tab = 'incidents'"
:class="{ active: active_tab === 'incidents' }"
class="tab_btn"
>Incident Tickets
</button>
<button
@click="active_tab = 'sites'"
:class="{ active: active_tab === 'sites' }"
class="tab_btn"
>Sites
</button>
<button
@click="active_tab = 'robots'"
:class="{ active: active_tab === 'robots' }"
class="tab_btn"
>Robots
</button>
</div>
<div class="content_frame">
<div v-if="active_tab === 'map'">
<Map :alarms="filtered_alarms" :robots="store.robots" map_height="80vh" />
</div>
<div v-if="active_tab === 'alarms'">
<div class="alarm_scroll">
<AlarmsCard v-if="filtered_alarms.length > 0" :data="filtered_alarms" />
<p v-else>All quiet in here... too quiet...</p>
</div>
</div>
<div v-if="active_tab === 'incidents'">
<div class="alarm_toolbar">
<div class="severity_filters">
<button class="tab_btn" :class="{ active: status_filter === null }" @click="status_filter = null">ALL</button>
<button class="tab_btn" :class="{ active: status_filter === 'active' }" @click="status_filter = 'active'">ACTIVE</button>
<button class="tab_btn" :class="{ active: status_filter === 'closed' }" @click="status_filter = 'closed'">CLOSED</button>
</div>
</div>
<div class="alarm_scroll">
<IncidentCard :data="filtered_incidents" />
</div>
</div>
<div v-if="active_tab === 'sites'">
<div class="alarm_scroll">
<Table :data="store.cellsites" link_prefix="/cellsites" />
</div>
</div>
<div v-if="active_tab === 'robots'">
<div class="alarm_scroll">
<Table :data="store.robots" link_prefix="/robots" />
</div>
</div>
<FilterRow
:count="active_tab === 'incidents' ? filtered_incidents.length : active_tab === 'robots' ? store.robots.length : filtered_alarms.length"
:label="active_tab === 'incidents' ? 'tickets' : active_tab === 'robots' ? 'robots' : 'alarms'"
/>
<TimelineRow @refresh="manual_refresh" @freeze="freeze" @snap_to_live="snap_to_live" />
</div>
</div>
</div>
</template>
<style scoped>
.content_frame {
border: 1px solid var(--theme-faint);
border-radius: 6px;
padding: 1rem;
transition: border-color 0.4s, box-shadow 0.4s;
box-shadow: 0 0 12px var(--theme-glow);
}
#map, .tabs {
margin-bottom: 2rem;
}
.alarm_toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.alarm_scroll {
height: 60vh;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,272 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { store } from '../store'
import { ALARMS_ENDPOINT, CELLSITES_ENDPOINT, INCIDENTS_ENDPOINT, ROBOTS_ENDPOINT } from '../main'
import { get_request } from '../ajax_requests'
import { use_timeline } from '../composables/use_timeline'
import { use_filtered_data } from '../composables/use_filtered_data'
import Nav from '../components/Nav.vue'
import Map from '../components/Map.vue'
import TimelineRow from '../components/TimelineRow.vue'
import FilterRow from '../components/FilterRow.vue'
import Table from '../components/Table.vue'
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const id = Number(route.params.id)
const incident = ref(null)
const associated_site = computed(() =>
store.cellsites.find(s => s.id === incident.value?.site_id)
)
const stats_fields = computed(() => [
['ID', incident.value?.id],
['STATUS', incident.value?.status],
['SEVERITY', incident.value?.severity],
['ASSIGNED TO', incident.value?.assigned_to],
['SITE', incident.value?.site_id],
['CREATED', incident.value?.created],
['UPDATED', incident.value?.updated],
['TEXT', incident.value?.text],
])
// TODO: Maybe instead of only fetching the specific site, we also fetch the other sites IF the user scrolls the map?
const fetch_all = async (before?: number) => {
const was_live = store.is_live
const site_id = incident.value?.site_id
if (!site_id) return
const alarm_url = before
? `${ALARMS_ENDPOINT}?id=${site_id}&before=${before}`
: `${ALARMS_ENDPOINT}?id=${site_id}`
const [alarms, robots] = await Promise.all([
get_request(alarm_url),
get_request(ROBOTS_ENDPOINT),
])
store.set_alarms(alarms)
store.set_robots(robots)
if (before === undefined && !store.is_frozen && was_live) {
// FIXME: repeated again
const now = Date.now()
store.timeline_max = now
store.timeline_position = now
} else {
store.timeline_max = Date.now()
}
}
const { timeline_position_debounced, is_live_debounced, manual_refresh, freeze, snap_to_live } = use_timeline(fetch_all)
const { visible_alarms, filtered_alarms } = use_filtered_data(is_live_debounced, timeline_position_debounced)
onMounted(async () => {
try {
incident.value = await get_request(`${INCIDENTS_ENDPOINT}/${id}`)
await fetch_all()
const all = store.alarms
if (all.length > 0) {
const oldest = Math.min(...all.map(r => new Date(r.created + ' UTC').getTime()))
store.timeline_min = store.timeline_min === 0 ? oldest : Math.min(store.timeline_min, oldest)
} else if (store.timeline_min === 0) {
store.timeline_min = Date.now() - 86400000
}
const site_exists = store.cellsites.some(s => s.id === incident.value?.site_id)
if (!site_exists && incident.value?.site_id) {
const data = await get_request(`${CELLSITES_ENDPOINT}/${incident.value.site_id}`)
store.set_cellsites(data)
}
} catch (error) {
console.error('Error fetching incident:', error)
} finally {
loading.value = false
}
})
</script>
<template>
<div :class="{ mode_historical: store.is_historical && !store.is_frozen, mode_frozen: store.is_frozen }">
<Nav />
<div class="site_container">
<div v-if="loading" class="loading-overlay">
<p>Loading...</p>
</div>
<div v-else-if="!incident" class="not-found">
<p>Not found</p>
</div>
<div v-else class="site_content">
<div class="stats">
<div class="site_id">INCIDENT {{ incident.id }}</div>
<div class="stat_row" v-for="[key, val] in stats_fields" :key="key">
<span class="stat_key">{{ key }}</span>
<span class="stat_val" :class="key === 'STATUS' ? incident.status : ''">{{ val }}</span>
</div>
</div>
<div class="map_container">
<Map
v-if="associated_site?.lat != null && associated_site?.lon != null"
:center="[associated_site.lat, associated_site.lon]"
:zoom="12"
:site_id="incident.site_id"
:robots="store.robots"
/>
</div>
<div class="data">
<div class="section_label">ASSOCIATED SITES</div>
<div class="site_list">
<div v-if="associated_site" class="site_link" @click="router.push(`/cellsites/${associated_site.id}`)">
<span class="site_link_id">SITE {{ associated_site.id }}</span>
<span class="site_link_meta">{{ associated_site.radio }} · {{ associated_site.lat }}, {{ associated_site.lon }}</span>
<span class="site_link_arrow"></span>
</div>
<p v-else>No associated site found.</p>
</div>
</div>
</div>
<FilterRow :count="filtered_alarms.length" label="alarms" />
<TimelineRow @refresh="manual_refresh" @freeze="freeze" @snap_to_live="snap_to_live" />
</div>
</div>
</template>
<style scoped>
.site_container {
position: relative;
padding: 1rem;
}
.site_content {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto;
grid-template-areas:
"stats map"
"data data";
gap: 1rem;
align-items: start;
}
.site_id {
font-size: 1.4rem;
color: var(--theme, rgba(255, 100, 0, 1));
margin-bottom: 1rem;
letter-spacing: 0.05em;
}
.stat_row {
display: flex;
justify-content: space-between;
padding: 0.3rem 0;
border-bottom: 1px solid var(--theme-faint);
font-size: 0.8rem;
}
.stat_key {
color: var(--theme-dim);
}
.stat_val {
color: rgba(255, 255, 255, 0.8);
}
.stat_val.active { color: rgba(220, 30, 30, 0.9); }
.stat_val.closed { color: rgba(100, 200, 100, 0.7); }
.stats { grid-area: stats; }
.map_container { grid-area: map; min-height: 400px; }
.data {
grid-area: data;
margin-top: 1rem;
}
.data_scroll {
height: 60vh;
overflow-y: auto;
}
.loading-overlay,
.not-found {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: rgba(10, 10, 10, 0.95);
z-index: 10;
font-size: 1.5rem;
text-align: center;
color: rgba(255, 100, 0, 0.8);
}
@media (max-width: 768px) {
.site_content {
grid-template-columns: 1fr;
grid-template-areas:
"stats"
"map"
"data";
}
.map_container { height: 300px; }
}
.section_label {
font-size: 0.7rem;
color: var(--theme-dim);
letter-spacing: 0.08em;
margin-bottom: 0.5rem;
}
.site_list {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.site_link {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.6rem 1rem;
background: rgba(20, 20, 20, 0.9);
border-left: 3px solid var(--theme-faint);
border-radius: 3px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.site_link:hover {
background: rgba(40, 40, 40, 0.9);
border-left-color: var(--theme);
}
.site_link_id {
color: var(--theme);
font-size: 0.85rem;
}
.site_link_meta {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.35);
flex: 1;
}
.site_link_arrow {
color: var(--theme-dim);
font-size: 0.85rem;
}
</style>

View File

@@ -0,0 +1,234 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { store } from '../store'
import { ALARMS_ENDPOINT, ROBOTS_ENDPOINT, CELLSITES_ENDPOINT } from '../main'
import { get_request } from '../ajax_requests'
import Nav from '../components/Nav.vue'
import Map from '../components/Map.vue'
import { use_timeline } from '../composables/use_timeline'
import { use_filtered_data } from '../composables/use_filtered_data'
import TimelineRow from '../components/TimelineRow.vue'
import FilterRow from '../components/FilterRow.vue'
const route = useRoute()
const router = useRouter()
const loading = ref(true)
const id = Number(route.params.id)
const robot = ref(null)
const stats_fields = computed(() => [
['NAME', robot.value?.name],
['STATUS', robot.value?.current_incident_id ? 'ON CALL' : 'IDLE'],
['INCIDENT', robot.value?.current_incident_id ?? '—'],
['SITE', robot.value?.current_site_id ?? '—'],
['TARGET SITE', robot.value?.target_site_id ?? '—'], // FIXME:
['LAT', robot.value?.lat],
['LON', robot.value?.lon],
['BASE LAT', robot.value?.base_lat],
['BASE LON', robot.value?.base_lon],
['UPDATED', robot.value?.updated],
])
const associated_site = computed(() =>
store.cellsites.find(s => s.id === robot.value?.current_site_id)
)
const fetch_all = async (before?: number) => {
const was_live = store.is_live
const alarm_url = before ? `${ALARMS_ENDPOINT}?before=${before}` : `${ALARMS_ENDPOINT}`
const [alarms, robotData] = await Promise.all([
get_request(alarm_url),
get_request(`${ROBOTS_ENDPOINT}/${id}`),
])
store.set_alarms(alarms)
robot.value = robotData
if (before === undefined && !store.is_frozen && was_live) {
// TODO: Repeated this fix, should break it out somewhere
const now = Date.now()
store.timeline_max = now
store.timeline_position = now
} else {
store.timeline_max = Date.now()
}
}
const { timeline_position_debounced, is_live_debounced, manual_refresh, freeze, snap_to_live } = use_timeline(fetch_all)
const { filtered_alarms } = use_filtered_data(is_live_debounced, timeline_position_debounced)
onMounted(async () => {
try {
// NOTE: Currently thinking we display the cellsites even on the robots page?
const [robotData, sitesData] = await Promise.all([
get_request(`${ROBOTS_ENDPOINT}/${id}`),
store.cellsites.length === 0 ? get_request(`${CELLSITES_ENDPOINT}`) : Promise.resolve(null),
])
robot.value = robotData
if (sitesData) store.set_cellsites(sitesData)
await fetch_all()
if (store.alarms.length > 0) {
const oldest = Math.min(...store.alarms.map(r => new Date(r.created + ' UTC').getTime()))
store.timeline_min = store.timeline_min === 0 ? oldest : Math.min(store.timeline_min, oldest)
} else if (store.timeline_min === 0) {
store.timeline_min = Date.now() - 86400000
}
} catch (error) {
console.error('Error fetching robot:', error)
// TODO: toasts or some banner for errors
} finally {
loading.value = false
}
})
</script>
<template>
<div :class="{ mode_historical: store.is_historical && !store.is_frozen, mode_frozen: store.is_frozen }">
<Nav />
<div class="robot_container">
<div v-if="loading" class="loading-overlay"><p>Loading...</p></div>
<div v-else-if="!robot" class="not-found"><p>Not found</p></div>
<div v-else class="robot_content">
<div class="stats">
<div class="page_id">{{ robot.name.toUpperCase() }}</div>
<div class="stat_row" v-for="[key, val] in stats_fields" :key="key">
<span class="stat_key">{{ key }}</span>
<span class="stat_val" :class="key === 'STATUS' ? (robot.current_incident_id ? 'on_call' : 'idle') : ''">
{{ val }}
</span>
</div>
</div>
<div class="map_container">
<Map
v-if="robot.lat != null && robot.lon != null"
:center="[robot.lat, robot.lon]"
:zoom="10"
:robots="[robot]"
:alarms="filtered_alarms"
/>
</div>
<div class="data">
<div class="section_label">CURRENT ASSIGNMENT</div>
<div class="link_list">
<div v-if="robot.current_incident_id" class="entity_link"
@click="router.push(`/incidents/${robot.current_incident_id}`)">
<span class="link_id">INC #{{ robot.current_incident_id }}</span>
<span class="link_arrow"></span>
</div>
<div v-if="associated_site" class="entity_link"
@click="router.push(`/cellsites/${associated_site.id}`)">
<span class="link_id">SITE {{ associated_site.id }}</span>
<span class="link_meta">{{ associated_site.radio }} · {{ associated_site.lat }}, {{ associated_site.lon }}</span>
<span class="link_arrow"></span>
</div>
<p v-if="!robot.current_incident_id && !associated_site">No active assignment.</p>
</div>
</div>
</div>
<FilterRow :count="filtered_alarms.length" label="alarms" />
<TimelineRow @refresh="manual_refresh" @freeze="freeze" @snap_to_live="snap_to_live" />
</div>
</div>
</template>
<style scoped>
.robot_container { position: relative; padding: 1rem; }
.robot_content {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto;
grid-template-areas:
"stats map"
"data data";
gap: 1rem;
align-items: start;
}
.page_id {
font-size: 1.4rem;
color: var(--theme);
margin-bottom: 1rem;
letter-spacing: 0.05em;
}
.stat_row {
display: flex;
justify-content: space-between;
padding: 0.3rem 0;
border-bottom: 1px solid var(--theme-faint);
font-size: 0.8rem;
}
.stat_key { color: var(--theme-dim); }
.stat_val { color: rgba(255, 255, 255, 0.8); }
.stat_val.on_call { color: rgba(220, 30, 30, 0.9); }
.stat_val.idle { color: rgba(100, 200, 100, 0.7); }
.stats { grid-area: stats; }
.map_container { grid-area: map; min-height: 400px; }
.data { grid-area: data; margin-top: 1rem; }
.section_label {
font-size: 0.7rem;
color: var(--theme-dim);
letter-spacing: 0.08em;
margin-bottom: 0.5rem;
}
.link_list { display: flex; flex-direction: column; gap: 0.3rem; }
.entity_link {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.6rem 1rem;
background: rgba(20, 20, 20, 0.9);
border-left: 3px solid var(--theme-faint);
border-radius: 3px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.entity_link:hover {
background: rgba(40, 40, 40, 0.9);
border-left-color: var(--theme);
}
.link_id { color: var(--theme); font-size: 0.85rem; }
.link_meta { font-size: 0.75rem; color: rgba(255, 255, 255, 0.35); flex: 1; }
.link_arrow { color: var(--theme-dim); font-size: 0.85rem; }
.loading-overlay, .not-found {
position: absolute; top: 0; left: 0;
width: 100%; height: 100%;
display: flex; justify-content: center; align-items: center;
background: rgba(10, 10, 10, 0.95);
z-index: 10; font-size: 1.5rem;
color: rgba(255, 100, 0, 0.8);
}
@media (max-width: 768px) {
.robot_content {
grid-template-columns: 1fr;
grid-template-areas: "stats" "map" "data";
}
.map_container { height: 300px; }
}
</style>

67
frontend/src/store.ts Normal file
View File

@@ -0,0 +1,67 @@
import { reactive } from 'vue'
export const store = reactive({
cellsites: [],
alarms: [],
incidents: [],
robots: [],
// FIXME: I am replacing the entire array, should merge when can. But it's okay for demo.
set_robots(data) {
this.robots = data
},
set_cellsites(data) {
this.cellsites = data
},
set_alarms(data) {
this.alarms = data
},
set_incidents(data) {
this.incidents = data
},
alarms_for_site(site_id) {
return this.alarms.filter(a => a.site_id === site_id)
},
severity_filter: null as number | null,
// active_tab: 'map' as 'map' | 'alarms' | 'incidents' | 'sites' | 'robots',
// NOTE: This is to retain the state when hitting the back button and be able to handle dynamic active tabs cross-component
detail_tabs: {},
// NOTE: The below manage state for the MODES: LIVE, Historical, Frozen.
is_frozen: false,
frozen_at: null as number | null,
refresh_interval_seconds: 30,
live_countdown: 30,
timeline_min: 0,
timeline_max: Date.now(),
timeline_position: Date.now(),
// NOTE: this is just in case the backend breaks, we FREEZE to avoid polling infinitely.
server_unreachable: false,
get is_live() {
return !this.is_frozen && this.timeline_position >= this.timeline_max - 2000 // NOTE: Buffer to deal with the flash of is_live, do not remove!
},
get is_historical() {
return !this.is_live
},
freeze() {
this.is_frozen = true
this.frozen_at = Date.now()
},
unfreeze() {
this.is_frozen = false
this.frozen_at = null
this.server_unreachable = false
},
set_timeline_position(val: number) {
this.timeline_position = val
},
set_refresh_interval(val: number) {
this.refresh_interval_seconds = val
},
})