demo version prepped
This commit is contained in:
39
frontend/.gitignore
vendored
Normal file
39
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Vitest
|
||||
__screenshots__/
|
||||
|
||||
# Vite
|
||||
*.timestamp-*-*.mjs
|
||||
42
frontend/README.md
Normal file
42
frontend/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# three60
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Recommended Browser Setup
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
6
frontend/env.d.ts
vendored
Normal file
6
frontend/env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
declare module '*.vue' {
|
||||
import { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>three60</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
3172
frontend/package-lock.json
generated
Normal file
3172
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
frontend/package.json
Normal file
34
frontend/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "three60",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/leaflet.markercluster": "^1.5.6",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.markercluster": "^1.5.3",
|
||||
"vue": "^3.5.30",
|
||||
"vue-router": "^5.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/node": "^24.12.0",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-vue-devtools": "^8.0.7",
|
||||
"vue-tsc": "^3.2.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
3
frontend/src/App.vue
Normal file
3
frontend/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
27
frontend/src/ajax_requests.ts
Normal file
27
frontend/src/ajax_requests.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
83
frontend/src/assets/base.css
Normal file
83
frontend/src/assets/base.css
Normal 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;
|
||||
}
|
||||
BIN
frontend/src/assets/fonts/DepartureMono-Regular.woff2
Normal file
BIN
frontend/src/assets/fonts/DepartureMono-Regular.woff2
Normal file
Binary file not shown.
1
frontend/src/assets/logo.svg
Normal file
1
frontend/src/assets/logo.svg
Normal 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 |
87
frontend/src/assets/main.css
Normal file
87
frontend/src/assets/main.css
Normal 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
349
frontend/src/assets/normalize.css
vendored
Normal 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;
|
||||
}
|
||||
169
frontend/src/components/AlarmsCard.vue
Normal file
169
frontend/src/components/AlarmsCard.vue
Normal 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>
|
||||
85
frontend/src/components/FilterRow.vue
Normal file
85
frontend/src/components/FilterRow.vue
Normal 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>
|
||||
123
frontend/src/components/IncidentCard.vue
Normal file
123
frontend/src/components/IncidentCard.vue
Normal 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>
|
||||
392
frontend/src/components/Map.vue
Normal file
392
frontend/src/components/Map.vue
Normal 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: '© OpenStreetMap © 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>
|
||||
78
frontend/src/components/Nav.vue
Normal file
78
frontend/src/components/Nav.vue
Normal 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>
|
||||
116
frontend/src/components/SimulationBanner.vue
Normal file
116
frontend/src/components/SimulationBanner.vue
Normal 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>
|
||||
89
frontend/src/components/Table.vue
Normal file
89
frontend/src/components/Table.vue
Normal 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>
|
||||
205
frontend/src/components/TimelineRow.vue
Normal file
205
frontend/src/components/TimelineRow.vue
Normal 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>
|
||||
39
frontend/src/composables/use_filtered_data.ts
Normal file
39
frontend/src/composables/use_filtered_data.ts
Normal 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 }
|
||||
}
|
||||
107
frontend/src/composables/use_timeline.ts
Normal file
107
frontend/src/composables/use_timeline.ts
Normal 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
59
frontend/src/main.ts
Normal 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')
|
||||
248
frontend/src/pages/Cellsite.vue
Normal file
248
frontend/src/pages/Cellsite.vue
Normal 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
193
frontend/src/pages/Home.vue
Normal 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>
|
||||
272
frontend/src/pages/Incident.vue
Normal file
272
frontend/src/pages/Incident.vue
Normal 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>
|
||||
234
frontend/src/pages/Robots.vue
Normal file
234
frontend/src/pages/Robots.vue
Normal 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
67
frontend/src/store.ts
Normal 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
|
||||
},
|
||||
})
|
||||
18
frontend/tsconfig.app.json
Normal file
18
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
// Extra safety for array and object lookups, but may have false positives.
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
// Path mapping for cleaner imports.
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
12
frontend/tsconfig.json
Normal file
12
frontend/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
],
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
|
||||
}
|
||||
27
frontend/tsconfig.node.json
Normal file
27
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,27 @@
|
||||
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
|
||||
{
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
// Most tools use transpilation instead of Node.js's native type-stripping.
|
||||
// Bundler mode provides a smoother developer experience.
|
||||
"module": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
// Include Node.js types and avoid accidentally including other `@types/*` packages.
|
||||
"types": ["node"],
|
||||
|
||||
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
|
||||
"noEmit": true,
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
26
frontend/vite.config.ts
Normal file
26
frontend/vite.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api/v1': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user