235 lines
7.6 KiB
Vue
235 lines
7.6 KiB
Vue
<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>
|