Skip to content

Commit 28f4003

Browse files
core: frontend: Add health_monitor
Signed-off-by: Patrick José Pereira <patrickelectric@gmail.com>
1 parent 520ff81 commit 28f4003

File tree

8 files changed

+579
-0
lines changed

8 files changed

+579
-0
lines changed
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<template>
2+
<div>
3+
<apexchart
4+
type="treemap"
5+
height="420"
6+
:options="chartOptions"
7+
:series="series"
8+
@dataPointSelection="onSelect"
9+
/>
10+
<div
11+
v-if="!loading && (!root || root.children.length === 0)"
12+
class="text-caption mt-2 grey--text text--darken-1"
13+
>
14+
No entries to display for this path.
15+
</div>
16+
</div>
17+
</template>
18+
19+
<script lang="ts">
20+
import { ApexOptions } from 'apexcharts'
21+
import Vue, { PropType } from 'vue'
22+
23+
import { DiskNode } from '@/types/disk'
24+
25+
interface TreemapDatum {
26+
x: string
27+
y: number
28+
path: string
29+
is_dir: boolean
30+
size_bytes: number
31+
}
32+
33+
export default Vue.extend({
34+
props: {
35+
root: {
36+
type: Object as PropType<DiskNode | null>,
37+
required: true,
38+
},
39+
loading: {
40+
type: Boolean,
41+
default: false,
42+
},
43+
},
44+
computed: {
45+
series(): { data: TreemapDatum[] }[] {
46+
if (!this.root) {
47+
return [{ data: [] }]
48+
}
49+
50+
const children = this.root.children || []
51+
return [{
52+
data: children.map((child) => ({
53+
x: child.name || '/',
54+
y: Math.max(child.size_bytes, 1),
55+
path: child.path,
56+
is_dir: child.is_dir,
57+
size_bytes: child.size_bytes,
58+
})),
59+
}]
60+
},
61+
chartOptions(): ApexOptions {
62+
return {
63+
chart: {
64+
toolbar: {
65+
show: false,
66+
},
67+
},
68+
legend: {
69+
show: false,
70+
},
71+
dataLabels: {
72+
enabled: true,
73+
formatter: (_: any, opts: any) => {
74+
const datum = opts.w.config.series[0].data as TreemapDatum[][opts.dataPointIndex]
75+
return `${datum.x}\n${this.formatBytes(datum.size_bytes)}`
76+
},
77+
style: {
78+
fontSize: '12px',
79+
},
80+
},
81+
tooltip: {
82+
y: {
83+
formatter: (value: number, opts: any) => {
84+
const datum = opts.w.config.series[0].data as TreemapDatum[][opts.dataPointIndex]
85+
return `${this.formatBytes(value)} (${datum.path})`
86+
},
87+
},
88+
},
89+
colors: ['#42a5f5', '#26c6da', '#7e57c2', '#66bb6a', '#ffa726', '#ef5350'],
90+
}
91+
},
92+
},
93+
methods: {
94+
onSelect(_event: MouseEvent, chartContext: any, config: any): void {
95+
const data = chartContext?.w?.config?.series?.[0]?.data as TreemapDatum[] | undefined
96+
if (!data || !data[config.dataPointIndex]) {
97+
return
98+
}
99+
const datum = data[config.dataPointIndex]
100+
this.$emit('select', datum.path, datum.is_dir)
101+
},
102+
formatBytes(bytes: number): string {
103+
if (bytes <= 0 || Number.isNaN(bytes)) {
104+
return '0 B'
105+
}
106+
const units = ['B', 'KB', 'MB', 'GB', 'TB']
107+
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
108+
const value = bytes / 1024 ** exponent
109+
return `${value.toFixed(value >= 10 || exponent === 0 ? 0 : 1)} ${units[exponent]}`
110+
},
111+
},
112+
})
113+
</script>
114+
115+
<style scoped>
116+
.apexcharts-canvas {
117+
max-height: 430px;
118+
}
119+
</style>
120+
121+
122+

core/frontend/src/menus.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ const menus = [
5050
advanced: true,
5151
text: 'Visualize disk usage and delete files/folders.',
5252
},
53+
{
54+
title: 'Health Monitor',
55+
icon: 'mdi-heart-pulse',
56+
route: '/tools/health-monitor',
57+
advanced: false,
58+
text: 'Monitor system and vehicle health warnings.',
59+
},
5360
{
5461
title: 'Log Browser',
5562
icon: 'mdi-math-log',

core/frontend/src/router/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ const routes: Array<RouteConfig> = [
4848
name: 'Disk',
4949
component: defineAsyncComponent(() => import('../views/Disk.vue')),
5050
},
51+
{
52+
path: '/tools/health-monitor',
53+
name: 'Health Monitor',
54+
component: defineAsyncComponent(() => import('../views/HealthMonitor.vue')),
55+
},
5156
{
5257
path: '/tools/web-terminal',
5358
name: 'Terminal',
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import {
2+
Action, Module, Mutation, VuexModule, getModule,
3+
} from 'vuex-module-decorators'
4+
5+
import store from '@/store'
6+
import { HealthHistory, HealthSummary } from '@/types/health-monitor'
7+
import back_axios, { isBackendOffline } from '@/utils/api'
8+
9+
@Module({ dynamic: true, store, name: 'health_monitor' })
10+
class HealthMonitorStore extends VuexModule {
11+
API_URL = '/health-monitor/v1.0/health'
12+
13+
summary: HealthSummary | null = null
14+
15+
history: HealthHistory | null = null
16+
17+
loading = false
18+
19+
error: string | null = null
20+
21+
@Mutation
22+
setSummary(value: HealthSummary | null): void {
23+
this.summary = value
24+
}
25+
26+
@Mutation
27+
setHistory(value: HealthHistory | null): void {
28+
this.history = value
29+
}
30+
31+
@Mutation
32+
setLoading(value: boolean): void {
33+
this.loading = value
34+
}
35+
36+
@Mutation
37+
setError(message: string | null): void {
38+
this.error = message
39+
}
40+
41+
@Action
42+
async fetchSummary(): Promise<void> {
43+
this.setLoading(true)
44+
this.setError(null)
45+
46+
await back_axios({
47+
method: 'get',
48+
url: `${this.API_URL}/summary`,
49+
timeout: 10000,
50+
})
51+
.then((response) => {
52+
this.setSummary(response.data as HealthSummary)
53+
})
54+
.catch((error) => {
55+
this.setSummary(null)
56+
if (isBackendOffline(error)) {
57+
return
58+
}
59+
this.setError(`Failed to fetch health summary: ${error.message}`)
60+
})
61+
.finally(() => {
62+
this.setLoading(false)
63+
})
64+
}
65+
66+
@Action
67+
async fetchHistory(limit = 200): Promise<void> {
68+
await back_axios({
69+
method: 'get',
70+
url: `${this.API_URL}/history`,
71+
params: { limit },
72+
timeout: 10000,
73+
})
74+
.then((response) => {
75+
this.setHistory(response.data as HealthHistory)
76+
})
77+
.catch((error) => {
78+
this.setHistory(null)
79+
if (isBackendOffline(error)) {
80+
return
81+
}
82+
this.setError(`Failed to fetch health history: ${error.message}`)
83+
})
84+
}
85+
}
86+
87+
const health_monitor = getModule(HealthMonitorStore)
88+
89+
export { HealthMonitorStore }
90+
export default health_monitor

core/frontend/src/types/frontend_services.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,13 @@ export const kraken_service: Service = {
119119
version: '0.1.0',
120120
}
121121

122+
export const health_monitor_service: Service = {
123+
name: 'Health Monitor',
124+
description: 'Service to monitor system and vehicle health warnings.',
125+
company: 'Blue Robotics',
126+
version: '0.1.0',
127+
}
128+
122129
export const parameters_service: Service = {
123130
name: 'Parameters service',
124131
description: 'Service to manage vehicle Parameters',
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
export type HealthSeverity = 'info' | 'warn' | 'error' | 'critical'
2+
export type HealthSource = 'system' | 'vehicle' | 'extension' | 'network'
3+
export type HealthEventType = 'problem_detected' | 'problem_resolved' | 'problem_updated'
4+
5+
export interface HealthProblem {
6+
id: string
7+
severity: HealthSeverity
8+
title: string
9+
details: string
10+
source: HealthSource
11+
timestamp: number
12+
metadata?: Record<string, unknown>
13+
first_seen_ms?: number
14+
last_seen_ms?: number
15+
}
16+
17+
export interface HealthEvent extends HealthProblem {
18+
type: HealthEventType
19+
}
20+
21+
export interface HealthSummary {
22+
active: HealthProblem[]
23+
updated_at: number
24+
}
25+
26+
export interface HealthHistory {
27+
events: HealthEvent[]
28+
}

0 commit comments

Comments
 (0)