Use local time
This commit is contained in:
@@ -1,64 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import Dashboard from './views/Dashboard.vue'
|
||||
import '@ztimson/css-utils/dist/css-utils.min.css';
|
||||
|
||||
const dark = ref(window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
|
||||
function toggleDark() { dark.value = !dark.value }
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
::-webkit-scrollbar {
|
||||
width: .45rem;
|
||||
height: .45rem;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgba(255, 255, 255, var(--tw-border-opacity));
|
||||
background-color: #d7d7d799;
|
||||
border-width: 1px;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: #0000;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--surface: #d0dae1;
|
||||
--border: #e2e8f0;
|
||||
--text: #0f172a;
|
||||
--text-muted: #64748b;
|
||||
--hover: #f1f5f9;
|
||||
--accent: #3b82f6;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--bg: #0a0a0a;
|
||||
--surface: #111111;
|
||||
--border: #222222;
|
||||
--text: #f1f5f9;
|
||||
--text-muted: #64748b;
|
||||
--hover: #1a1a1a;
|
||||
--accent: #3b82f6;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-wrap {
|
||||
height: 100vh;
|
||||
|
||||
@@ -1,4 +1,41 @@
|
||||
@import './base.css';
|
||||
@import '@ztimson/css-utils/dist/css-utils.min.css';
|
||||
|
||||
::-webkit-scrollbar { height: 4px; width: 4px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--surface: #d0dae1;
|
||||
--border: #e2e8f0;
|
||||
--text: #0f172a;
|
||||
--text-muted: #64748b;
|
||||
--hover: #f1f5f9;
|
||||
--accent: #3b82f6;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--bg: #0a0a0a;
|
||||
--surface: #111111;
|
||||
--border: #222222;
|
||||
--text: #f1f5f9;
|
||||
--text-muted: #64748b;
|
||||
--hover: #1a1a1a;
|
||||
--accent: #3b82f6;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
@@ -27,3 +64,25 @@ a,
|
||||
place-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
|
||||
.card-title {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
padding: 10px 12px 6px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 6px 0;
|
||||
}
|
||||
}
|
||||
|
||||
28
client/src/components/EnvironmentCard.vue
Normal file
28
client/src/components/EnvironmentCard.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import {ref, onMounted} from 'vue';
|
||||
import {api} from '../services/api';
|
||||
import MetricRow from './MetricRow.vue';
|
||||
|
||||
const d = ref<any>(null);
|
||||
onMounted(async () => d.value = await api.current());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card" v-if="d">
|
||||
<div class="card-title">🌡️ Environment</div>
|
||||
|
||||
<MetricRow label="Temperature" :value="`${d.temperature?.toFixed(1)}°C`" metric-key="temperature" :data="d" />
|
||||
<MetricRow label="Feels Like" :value="`${d.heat_index?.toFixed(1)}°C`" metric-key="heat_index" :data="d" />
|
||||
<MetricRow label="Dew Point" :value="`${d.dew_point?.toFixed(1)}°C`" metric-key="dew_point" :data="d" />
|
||||
<div class="divider" />
|
||||
<MetricRow label="Humidity" :value="`${d.humidity?.toFixed(1)}%`" metric-key="humidity" :data="d" />
|
||||
<MetricRow label="Abs Humidity" :value="`${d.humidity_abs?.toFixed(2)} g/m³`" metric-key="humidity_abs" :data="d" />
|
||||
<MetricRow label="Vapor Pressure Deficit" :value="`${d.vapor_pressure_deficit?.toFixed(2)} kPa`" metric-key="vapor_pressure_deficit" :data="d" />
|
||||
<div class="divider" />
|
||||
<MetricRow label="Pressure" :value="`${d.pressure_hpa?.toFixed(1)} hPa`" metric-key="pressure_hpa" :data="d" />
|
||||
<MetricRow label="Sea Level Pressure" :value="`${d.pressure_slp?.toFixed(1)} hPa`" metric-key="pressure_slp" :data="d" />
|
||||
<MetricRow label="Trend" :value="d.pressure_trend" />
|
||||
<div class="divider" />
|
||||
<MetricRow label="Visibility" :value="`${d.visibility?.toFixed(1)} km`" metric-key="visibility" :data="d" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -37,9 +37,6 @@ onMounted(async () => {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 6px;
|
||||
width: 100%;
|
||||
|
||||
&::-webkit-scrollbar { height: 4px; }
|
||||
&::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
}
|
||||
|
||||
.day {
|
||||
|
||||
@@ -1,87 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { METRICS } from '../services/units'
|
||||
import MetricChart from './MetricChart.vue'
|
||||
import type { DataRow } from '../services/api'
|
||||
import {computed, ref, watch} from 'vue';
|
||||
import {api} from '../services/api';
|
||||
|
||||
const props = defineProps<{ metricKey: string | null; currentData: DataRow }>()
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
const props = defineProps<{metricKey: string | null}>();
|
||||
const emit = defineEmits<{(e: 'close'): void}>();
|
||||
|
||||
const meta = computed(() => props.metricKey ? METRICS[props.metricKey] : null)
|
||||
const mode = ref<'hourly' | 'daily'>('hourly');
|
||||
const rows = ref<any[]>([]);
|
||||
const W = 600, H = 200, PAD = 30;
|
||||
|
||||
watch([() => props.metricKey, mode], async ([key]) => {
|
||||
if(!key) return;
|
||||
const now = new Date(), past = new Date(), future = new Date();
|
||||
past.setDate(past.getDate() - 3);
|
||||
future.setDate(future.getDate() + 3);
|
||||
rows.value = mode.value === 'hourly'
|
||||
? await api.hourly(past, future)
|
||||
: await api.daily(past, future);
|
||||
}, {immediate: true});
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const points = computed(() => {
|
||||
if(!rows.value.length || !props.metricKey) return [];
|
||||
return rows.value.map(r => ({
|
||||
t: new Date(r.time as string).getTime(),
|
||||
v: r[props.metricKey!] as number | null,
|
||||
})).filter(p => p.v != null);
|
||||
});
|
||||
|
||||
const {minT, maxT, minV, maxV, path, currentX} = computed(() => {
|
||||
const pts = points.value;
|
||||
if(!pts.length) return {minT:0, maxT:1, minV:0, maxV:1, path:'', currentX: 0};
|
||||
const ts = pts.map(p => p.t), vs = pts.map(p => p.v as number);
|
||||
const minT = Math.min(...ts), maxT = Math.max(...ts);
|
||||
const minV = Math.min(...vs), maxV = Math.max(...vs);
|
||||
const xOf = (t: number) => PAD + ((t - minT) / (maxT - minT || 1)) * (W - PAD * 2);
|
||||
const yOf = (v: number) => H - PAD - ((v - minV) / (maxV - minV || 1)) * (H - PAD * 2);
|
||||
const nowT = now.getTime();
|
||||
const currentX = xOf(nowT);
|
||||
|
||||
// split historic vs future
|
||||
const hist = pts.filter(p => p.t <= nowT);
|
||||
const fut = pts.filter(p => p.t >= nowT);
|
||||
const histPath = hist.map((p, i) => `${i===0?'M':'L'} ${xOf(p.t).toFixed(1)} ${yOf(p.v as number).toFixed(1)}`).join(' ');
|
||||
const futPath = fut.map((p, i) => `${i===0?'M':'L'} ${xOf(p.t).toFixed(1)} ${yOf(p.v as number).toFixed(1)}`).join(' ');
|
||||
const path = {hist: histPath, fut: futPath};
|
||||
return {minT, maxT, minV, maxV, path, currentX};
|
||||
}).value;
|
||||
|
||||
const currentDot = computed(() => {
|
||||
const pts = points.value;
|
||||
if(!pts.length || !props.metricKey) return null;
|
||||
const nowT = now.getTime();
|
||||
const closest = pts.reduce((a, b) => Math.abs(a.t - nowT) < Math.abs(b.t - nowT) ? a : b);
|
||||
const xOf = (t: number) => PAD + ((t - minT) / (maxT - minT || 1)) * (W - PAD * 2);
|
||||
const yOf = (v: number) => H - PAD - ((v - minV) / (maxV - minV || 1)) * (H - PAD * 2);
|
||||
return {x: xOf(closest.t), y: yOf(closest.v as number), v: closest.v};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
backdrop-filter: blur(4px);
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.title { font-size: 16px; font-weight: 700; color: var(--text); }
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
&:hover { background: var(--hover); }
|
||||
}
|
||||
}
|
||||
.mode-toggle {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover { background: var(--hover); }
|
||||
button {
|
||||
font-size: 11px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
&.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
}
|
||||
}
|
||||
svg { width: 100%; overflow: visible; }
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="metricKey" class="backdrop" @click.self="emit('close')">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">
|
||||
<span>{{ meta?.icon }}</span>
|
||||
<span>{{ meta?.label ?? metricKey }}</span>
|
||||
</div>
|
||||
<button class="close-btn" @click="emit('close')">✕</button>
|
||||
</div>
|
||||
<MetricChart :metric-key="metricKey" :current-data="currentData" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="metricKey" class="backdrop" @click.self="emit('close')">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="title">{{ metricKey }}</span>
|
||||
<button class="close-btn" @click="emit('close')">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="mode-toggle">
|
||||
<button :class="{active: mode === 'hourly'}" @click="mode = 'hourly'">Hourly</button>
|
||||
<button :class="{active: mode === 'daily'}" @click="mode = 'daily'">Daily</button>
|
||||
</div>
|
||||
|
||||
<svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none">
|
||||
<!-- grid -->
|
||||
<line :x1="PAD" :y1="PAD" :x2="PAD" :y2="H-PAD" stroke="var(--border)" stroke-width="1"/>
|
||||
<line :x1="PAD" :y1="H-PAD" :x2="W-PAD" :y2="H-PAD" stroke="var(--border)" stroke-width="1"/>
|
||||
<!-- now line -->
|
||||
<line :x1="currentX" :y1="PAD" :x2="currentX" :y2="H-PAD" stroke="var(--accent)" stroke-width="1" stroke-dasharray="3 2" opacity="0.5"/>
|
||||
<!-- historic solid -->
|
||||
<path :d="path.hist" fill="none" stroke="var(--accent)" stroke-width="2"/>
|
||||
<!-- future dashed -->
|
||||
<path :d="path.fut" fill="none" stroke="var(--accent)" stroke-width="2" stroke-dasharray="5 3" opacity="0.6"/>
|
||||
<!-- current dot -->
|
||||
<circle v-if="currentDot" :cx="currentDot.x" :cy="currentDot.y" r="4" fill="var(--accent)"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
@@ -82,8 +82,8 @@ onMounted(async () => {
|
||||
<span class="chevron" :class="{ open }">▼</span>
|
||||
</div>
|
||||
|
||||
<div style="max-height: 50vh; overflow-y: auto">
|
||||
<div v-if="open" class="hour-list">
|
||||
<div v-if="open" style="height: 50vh; overflow-y: auto">
|
||||
<div class="hour-list">
|
||||
<template v-if="hours?.length">
|
||||
<div v-for="h in hours" class="hour-row">
|
||||
<span class="hour-time">{{ formatDate('h A', h.time) }}</span>
|
||||
|
||||
33
client/src/components/MetricRow.vue
Normal file
33
client/src/components/MetricRow.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import {inject} from 'vue';
|
||||
|
||||
const props = defineProps<{label: string; value: any; metricKey?: string; data?: any}>();
|
||||
const openGraph = inject<(key: string, data: any) => void>('openGraph');
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
&:hover { background: var(--hover); }
|
||||
}
|
||||
|
||||
.label { color: var(--text-muted); }
|
||||
.value { font-weight: 600; color: var(--text); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="row" :class="{clickable: !!metricKey}" @click="metricKey && openGraph?.(metricKey, data)">
|
||||
<span class="label">{{ label }}</span>
|
||||
<span class="value">{{ value ?? '—' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,28 +1,31 @@
|
||||
export const BASE = import.meta.env.DEV ? 'http://10.69.5.23' : ''
|
||||
export const BASE = import.meta.env.DEV ? 'http://10.69.5.23' : '';
|
||||
|
||||
export type DataRow = Record<string, number | string | null>
|
||||
|
||||
const cache: any = {};
|
||||
|
||||
async function get<T>(path: string, params: Record<string, string> = {}): Promise<T> {
|
||||
const url = new URL(`${BASE}${path}`, window.location.origin)
|
||||
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v))
|
||||
if(cache[url.toString()]) return cache[url.toString()];
|
||||
cache[url.toString()] = await fetch(url.toString()).then(resp => resp.json()).finally(() => { delete cache[url.toString()]; });
|
||||
return cache[url.toString()]
|
||||
const url = new URL(`${BASE}${path}`, window.location.origin);
|
||||
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
|
||||
const key = url.toString();
|
||||
if(cache[key]) return cache[key];
|
||||
cache[key] = fetch(url.toString())
|
||||
.then(resp => resp.json())
|
||||
.finally(() => delete cache[key]);
|
||||
return cache[key];
|
||||
}
|
||||
|
||||
export const api = {
|
||||
position: (fields?: string) => get<{latitude: number, longitude: number, altitude: number}>('/api/position', fields ? { fields } : {}),
|
||||
current: (fields?: string) => get<DataRow>('/api/current', fields ? { fields } : {}),
|
||||
hourly: (start?: Date, end?: Date, fields?: string) => get<DataRow[]>('/api/hourly', {
|
||||
...(start ? { start: start.toISOString().slice(0, 10) } : {}),
|
||||
...(end ? { end: end.toISOString().slice(0, 10) } : {}),
|
||||
...(fields ? { fields } : {})
|
||||
}),
|
||||
daily: (start?: Date, end?: Date, fields?: string) => get<DataRow[]>('/api/daily', {
|
||||
...(start ? { start: start.toISOString().slice(0, 10) } : {}),
|
||||
...(end ? { end: end.toISOString().slice(0, 10) } : {}),
|
||||
...(fields ? { fields } : {})
|
||||
}),
|
||||
}
|
||||
position: (fields?: string) => get<{latitude: number, longitude: number, altitude: number}>('/api/position', fields ? {fields} : {}),
|
||||
current: (fields?: string) => get<DataRow>('/api/current', fields ? {fields} : {}),
|
||||
hourly: (start?: Date, end?: Date, fields?: string) => get<DataRow[]>('/api/hourly', {
|
||||
...(start ? {start: start.toISOString().slice(0, 10)} : {}),
|
||||
...(end ? {end: end.toISOString().slice(0, 10)} : {}),
|
||||
...(fields ? {fields} : {})
|
||||
}),
|
||||
daily: (start?: Date, end?: Date, fields?: string) => get<DataRow[]>('/api/daily', {
|
||||
...(start ? {start: start.toISOString().slice(0, 10)} : {}),
|
||||
...(end ? {end: end.toISOString().slice(0, 10)} : {}),
|
||||
...(fields ? {fields} : {})
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
`<script setup lang="ts">
|
||||
import EnvironmentCard from '@/components/EnvironmentCard.vue';
|
||||
import GraphModal from '@/components/GraphModal.vue';
|
||||
import HourlyForecast from '@/components/HourlyForecast.vue';
|
||||
import {formatDate} from '@ztimson/utils';
|
||||
import {onMounted, onUnmounted, ref} from 'vue';
|
||||
import {onMounted, onUnmounted, provide, ref} from 'vue';
|
||||
import MapView from '../components/MapView.vue';
|
||||
import CurrentWeather from '../components/CurrentWeather.vue';
|
||||
import ForecastStrip from '../components/ForecastStrip.vue';
|
||||
@@ -10,8 +12,11 @@ const day = ref(formatDate('dddd'));
|
||||
const date = ref(formatDate('MMMM D'));
|
||||
const time = ref(formatDate('h:mm A'));
|
||||
const props = defineProps<{dark: boolean}>();
|
||||
const selectedMetric = ref<string | null>(null);
|
||||
let clock: ReturnType<typeof setInterval>;
|
||||
|
||||
provide('openGraph', (key: string) => selectedMetric.value = key);
|
||||
|
||||
onMounted(async () => {
|
||||
clock = setInterval(() => {
|
||||
day.value = formatDate('dddd');
|
||||
@@ -161,13 +166,11 @@ onUnmounted(() => {
|
||||
<CurrentWeather />
|
||||
<HourlyForecast />
|
||||
<ForecastStrip />
|
||||
|
||||
<EnvironmentCard />
|
||||
</div>
|
||||
|
||||
<!-- <GraphModal-->
|
||||
<!-- :metric-key="selectedMetric"-->
|
||||
<!-- :current-data="current"-->
|
||||
<!-- @close="selectedMetric = null"-->
|
||||
<!-- />-->
|
||||
<GraphModal :metric-key="selectedMetric" @close="selectedMetric = null" />
|
||||
</div>
|
||||
</template>
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user