Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions core/frontend/src/components/app/VehicleBanner.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,19 @@
>
({{ system_id }})
</span>
</p>
<div class="action-buttons-container">
<v-btn
class="mx-2 edit-icon"
class="mx-1"
fab
dark
x-small
@click="openDialog"
>
<v-icon>
mdi-pencil
</v-icon>
<v-icon>mdi-pencil</v-icon>
</v-btn>
</p>
<VehiclePicker />
</div>
<v-spacer />
<image-picker
size="35px"
Expand Down Expand Up @@ -74,11 +75,13 @@ import bag from '@/store/bag'
import beacon from '@/store/beacon'

import ImagePicker from './ImagePicker.vue'
import VehiclePicker from './VehiclePicker.vue'

export default Vue.extend({
name: 'VehicleBanner',
components: {
ImagePicker,
VehiclePicker,
},
data() {
return {
Expand Down Expand Up @@ -166,7 +169,7 @@ export default Vue.extend({
})
</script>
<style scoped>
#vehicle-name:hover .edit-icon{
#vehicle-banner:hover .action-buttons-container{
display: inline-flex;
}

Expand All @@ -181,10 +184,13 @@ export default Vue.extend({
margin-bottom: 0 !important;
position: relative;
}
.edit-icon {

.action-buttons-container {
display: none;
right: 0;
bottom: 0;
position: absolute;
position: fixed;
right: 70px;
z-index: 10;
align-items: center;
gap: 4px;
}
</style>
176 changes: 176 additions & 0 deletions core/frontend/src/components/app/VehiclePicker.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
<template>
<div class="vehicle-picker">
<v-btn
:loading="loading"
class="mx-1"
fab
dark
x-small
@click="toggleDropdown"
>
<v-icon>{{ expanded ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
</v-btn>

<v-menu
v-model="expanded"
:close-on-content-click="false"
offset-y
min-width="150"
>
<template #activator="{ }" />

<v-card>
<v-card-title class="subtitle-2 pa-2">
Other Vehicles detected on your network
<v-spacer />
<v-btn
icon
x-small
@click="refreshServices"
>
<v-icon small>
mdi-refresh
</v-icon>
</v-btn>
</v-card-title>

<v-divider />

<v-list v-if="availableVehicles.length > 0" dense>
<v-list-item
v-for="vehicle in availableVehicles"
:key="vehicle.hostname"
>
<v-list-item-avatar>
<v-img
v-if="vehicle.imagePath"
:src="vehicle.imagePath"
:alt="vehicle.hostname"
aspect-ratio="1"
class="vehicle-image"
>
<template #error>
<v-icon color="primary">
mdi-submarine
</v-icon>
</template>
</v-img>
<v-icon v-else color="primary">
mdi-submarine
</v-icon>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title>
<span class="vehicle-name">
{{ vehicle.hostname }}
</span>
{{ allMyIps.includes(vehicle.ips[0]) ? '(This Vehicle)' : '' }}
</v-list-item-title>
<v-list-item-title v-for="ip in vehicle.ips" :key="ip" class="caption" @click="navigateToVehicle(ip)">
{{ ip }}
<v-icon x-small color="primary">
mdi-launch
</v-icon>
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>

<v-card-text v-else-if="!loading" class="text-center text--secondary">
<div v-if="error">
<v-icon color="error">
mdi-alert-circle
</v-icon>
<div class="caption">
{{ error }}
</div>
</div>
<div v-else>
No vehicles discovered
</div>
</v-card-text>

<v-card-text v-else class="text-center">
<v-progress-circular
indeterminate
size="24"
width="2"
/>
<div class="caption mt-2">
Discovering vehicles...
</div>
</v-card-text>
</v-card>
</v-menu>
</div>
</template>

<script lang="ts">
import Vue from 'vue'

import beacon from '@/store/beacon'
import system_information from '@/store/system-information'

export default Vue.extend({
name: 'VehiclePicker',
data() {
return {
expanded: false,
}
},
computed: {
loading(): boolean {
return beacon.vehicles_loading
},
error(): string | null {
return beacon.vehicles_error
},
availableVehicles() {
return beacon.available_vehicles
},
allMyIps(): string[] {
return system_information.system?.network.flatMap((network) => network.ips.map((ip) => ip.split('/')[0])) || []
},
},
methods: {
toggleDropdown() {
this.expanded = !this.expanded
if (this.expanded && this.availableVehicles.length === 0) {
beacon.fetchDiscoveredServices()
}
},

async refreshServices() {
await beacon.fetchDiscoveredServices()
},

navigateToVehicle(ip: string) {
window.open(`http://${ip}`, '_blank')
this.expanded = false
},
},
})
</script>

<style scoped>
.vehicle-picker {
display: inline-block;
}

.caption:hover {
background-color: rgba(0, 0, 0, 0.04);
cursor: pointer;
}

.vehicle-image {
border-radius: 4px;
}

.v-list-item:hover {
background-color: rgba(0, 0, 0, 0.04);
}

.vehicle-name {
font-size: 1.2rem;
}
</style>
97 changes: 97 additions & 0 deletions core/frontend/src/store/beacon.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import axios from 'axios'
import {
Action,
getModule, Module, Mutation, VuexModule,
Expand All @@ -10,6 +11,16 @@ import { Domain } from '@/types/beacon'
import { beacon_service } from '@/types/frontend_services'
import back_axios, { isBackendOffline } from '@/utils/api'

interface DiscoveredServices {
[ip: string]: string[]
}

export interface Vehicle {
ips: string[]
hostname: string
imagePath?: string
}

const notifier = new Notifier(beacon_service)

let prefetched_domains = false
Expand Down Expand Up @@ -37,6 +48,12 @@ class BeaconStore extends VuexModule {

vehicle_name = ''

available_vehicles: Vehicle[] = []

vehicles_loading = false

vehicles_error: string | null = null

fetchAvailableDomainsTask = new OneMoreTime(
{ delay: 5000 },
)
Expand Down Expand Up @@ -90,6 +107,21 @@ class BeaconStore extends VuexModule {
this.listeners_count -= 1
}

@Mutation
setAvailableVehicles(vehicles: Vehicle[]): void {
this.available_vehicles = vehicles
}

@Mutation
setVehiclesLoading(loading: boolean): void {
this.vehicles_loading = loading
}

@Mutation
setVehiclesError(error: string | null): void {
this.vehicles_error = error
}

@Action
async setHostname(hostname: string): Promise<boolean> {
hostname = hostname.trim().toLowerCase()
Expand Down Expand Up @@ -234,6 +266,71 @@ class BeaconStore extends VuexModule {
}
}

@Action
async fetchDiscoveredServices(): Promise<void> {
this.setVehiclesLoading(true)
this.setVehiclesError(null)
this.setAvailableVehicles([])

try {
const response = await back_axios({
method: 'get',
url: `${this.API_URL}/discovered_services`,
timeout: 10000,
})

const discoveredServices: DiscoveredServices = response.data
const ips = Object.keys(discoveredServices)

if (ips.length === 0) {
this.setVehiclesError('No vehicles discovered')
this.setVehiclesLoading(false)
return
}

const vehicles: Vehicle[] = []

await Promise.all(ips.map(async (ip) => {
try {
const hostnameResponse = await axios.get(`http://${ip}/beacon/v1.0/hostname`, {
timeout: 5000,
})

let imagePath: string | undefined
try {
const imageResponse = await axios.get(`http://${ip}/bag/v1.0/get/vehicle.image_path`, {
timeout: 3000,
})
imagePath = `http://${ip}${imageResponse.data.url}`
} catch {
// Image not available, use fallback
}

vehicles.push({
ips: [ip],
hostname: hostnameResponse.data,
imagePath,
})
} catch (error) {
console.warn(`Failed to fetch hostname for ${ip}:`, error)
}
}))

vehicles.sort((a, b) => a.hostname.localeCompare(b.hostname))
this.setAvailableVehicles(vehicles)

if (vehicles.length === 0) {
this.setVehiclesError('No accessible vehicles found')
}
} catch (error) {
console.error('Failed to fetch discovered services:', error)
this.setVehiclesError('Failed to discover vehicles')
this.setAvailableVehicles([])
} finally {
this.setVehiclesLoading(false)
}
}

@Action
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async registerBeaconListener(object: any): Promise<void> {
Expand Down
Loading