ui/src/views/Dashboard/index.vue (513 lines of code) (raw):
<!--
~ Licensed to Apache Software Foundation (ASF) under one or more contributor
~ license agreements. See the NOTICE file distributed with
~ this work for additional information regarding copyright
~ ownership. Apache Software Foundation (ASF) licenses this file to you under
~ the Apache License, Version 2.0 (the "License"); you may
~ not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing,
~ software distributed under the License is distributed on an
~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
~ KIND, either express or implied. See the License for the
~ specific language governing permissions and limitations
~ under the License.
-->
<script setup>
import { ref, watchEffect, computed } from 'vue';
import { getGroupList, getTableList } from '@/api/index';
import { Shortcuts } from '../../components/common/data';
const tableLayout = ref('auto');
const autoRefresh = ref('off');
const hasMonitoring = ref(true);
const options = ref([
{ value: 'off', label: 'Off' },
{ value: 15000, label: '15 seconds' },
{ value: 30000, label: '30 seconds' },
{ value: 60000, label: '1 minute' },
{ value: 300000, label: '5 minutes' },
]);
const utcTime = ref({
end: '',
oneMinuteAgo: '',
});
const commonParams = {
groups: ['_monitoring'],
offset: 0,
orderBy: {
indexRuleName: '',
sort: 'SORT_UNSPECIFIED',
},
fieldProjection: {
names: ['value'],
},
};
const tagProjectionUptime = {
tagFamilies: [
{
name: 'default',
tags: ['node_type', 'node_id', 'grpc_address', 'http_address'],
},
],
};
const tagProjection = {
tagFamilies: [
{
name: 'default',
tags: ['node_id', 'kind'],
},
],
};
const tagProjectionDisk = {
tagFamilies: [
{
name: 'default',
tags: ['node_id', 'kind', 'path'],
},
],
};
const nodes = ref([]);
const colors = [
{ color: '#5cb87a', percentage: 50 },
{ color: '#edc374', percentage: 80 },
{ color: '#f56c6c', percentage: 100 },
];
// State for date picker default 30 mins
const dateRange = ref([new Date(Date.now() - 30 * 60 * 1000), new Date()]);
const timezoneOffset = computed(() => {
const offset = new Date().getTimezoneOffset();
const hours = Math.floor(Math.abs(offset) / 60);
const minutes = Math.abs(offset) % 60;
const sign = offset <= 0 ? '+' : '-';
return `UTC${sign}${hours}:${minutes.toString().padStart(2, '0')}`;
});
const truncatePath = (path) => {
if (path.length <= 35) return path;
return path.slice(0, 5) + '...' + path.slice(-30);
};
const isTruncated = (path) => {
return path.length > 35;
};
function formatUptime(seconds) {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
return `${hrs > 0 ? `${hrs}h ` : ''}${mins}m ${secs}s`;
}
function extractAddress(fullAddress) {
const parts = fullAddress.split(':');
return parts[parts.length - 1];
}
function formatBytes(bytes) {
if (bytes === 0 || bytes === undefined) return 'N/A';
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i];
}
function checkMonitoring(data) {
if (!data.group || !Array.isArray(data.group)) {
return false;
}
for (let item of data.group) {
if (item.metadata && item.metadata.name === '_monitoring') {
return true;
}
}
return false;
}
async function fetchNodes() {
const groupList = await fetchGroupList();
if (!checkMonitoring(groupList)) {
hasMonitoring.value = false;
return;
}
getCurrentUTCTime();
const [upTimeDataPoints, cpuDataPoints, memoryDataPoints, diskDataPoints] = await Promise.all([
fetchDataPoints('up_time', tagProjectionUptime),
fetchDataPoints('cpu_state', tagProjection),
fetchDataPoints('memory_state', tagProjection),
fetchDataPoints('disk', tagProjectionDisk),
]);
// create table rows using uptime datapoints
const rows = getLatestForEachNode(upTimeDataPoints).map((item) => {
const tags = item.tagFamilies[0].tags;
const nodeType = tags.find((tag) => tag.key === 'node_type').value.str.value;
const nodeId = tags.find((tag) => tag.key === 'node_id').value.str.value;
const grpcAddress = extractAddress(tags.find((tag) => tag.key === 'grpc_address').value.str.value);
const httpAddress = extractAddress(tags.find((tag) => tag.key === 'http_address').value.str.value);
const value = item.fields.find((field) => field.name === 'value').value.float.value;
return {
node_id: nodeId,
node_type: nodeType,
grpc_address: grpcAddress,
http_address: httpAddress,
uptime: value,
};
});
rows.sort((a, b) => {
return a.node_id.localeCompare(b.node_id);
});
// group by other metrics
const cpuData = groupBy(cpuDataPoints, 'kind');
const memoryData = groupBy(memoryDataPoints, 'kind');
const paths = groupBy(diskDataPoints, 'path');
const sortedPaths = sortObject(paths);
const diskData = Object.keys(sortedPaths).reduce((acc, path) => {
acc[path] = groupBy(sortedPaths[path], 'kind');
return acc;
}, {});
rows.forEach((row) => {
row.cpu = getLatestField(cpuData.user, row.node_id);
row.memory = {
used: getLatestField(memoryData.used, row.node_id),
total: getLatestField(memoryData.total, row.node_id),
used_percent: getLatestField(memoryData.used_percent, row.node_id),
};
if (row.node_type != 'liason') {
row.disk = {};
for (const path in diskData) {
row.disk[path] = {
used: getLatestField(diskData[path].used, row.node_id),
total: getLatestField(diskData[path].total, row.node_id),
used_percent: getLatestField(diskData[path].used_percent, row.node_id),
};
}
}
});
// Post-process row data
rows.forEach((row) => {
row.uptime = formatUptime(row.uptime);
});
nodes.value = rows;
}
function getCurrentUTCTime() {
const end = dateRange.value[1];
utcTime.value.end = end.toISOString();
const oneMinuteAgo = new Date(end.getTime() - 60000);
utcTime.value.oneMinuteAgo = oneMinuteAgo.toISOString();
}
async function fetchDataPoints(type, tagProjection) {
const params = JSON.parse(JSON.stringify(commonParams));
params.name = type;
params.timeRange = {
begin: utcTime.value.oneMinuteAgo,
end: utcTime.value.end,
};
params.tagProjection = tagProjection;
const res = await getTableList(params, 'measure');
if (res.status === 200) {
return res.data.dataPoints;
}
return null;
}
async function fetchGroupList() {
const res = await getGroupList();
if (res.status === 200) {
return res.data;
}
return null;
}
function groupBy(data, key) {
return data.reduce((acc, obj) => {
const keyValue = obj.tagFamilies[0].tags.find((tag) => tag.key === key).value.str.value;
if (!acc[keyValue]) {
acc[keyValue] = [];
}
acc[keyValue].push(obj);
return acc;
}, {});
}
function sortObject(groupedObject) {
// sort by key
const keys = Object.keys(groupedObject);
keys.sort();
const sortedObject = {};
keys.forEach((key) => {
sortedObject[key] = groupedObject[key];
});
return sortedObject;
}
// depuplicate by getting the latest data for each node id
function getLatestForEachNode(data) {
const nodeDataMap = {};
data.forEach((item) => {
const nodeIdTag = item.tagFamilies[0].tags.find((tag) => tag.key === 'node_id');
const nodeId = nodeIdTag.value.str.value;
const timestamp = new Date(item.timestamp).getTime();
if (!nodeDataMap[nodeId] || timestamp > nodeDataMap[nodeId].timestamp) {
nodeDataMap[nodeId] = { ...item, timestamp };
}
});
const uniqueNodeData = Object.values(nodeDataMap).map((item) => {
delete item.timestamp;
return item;
});
return uniqueNodeData;
}
// get latest field value by nodeId
function getLatestField(data, nodeId) {
let latestItem = null;
let latestTimestamp = 0;
// Iterate through each item in the data array
data.forEach((item) => {
const nodeIdTag = item.tagFamilies[0].tags.find((tag) => tag.key === 'node_id');
const currentNodeId = nodeIdTag.value.str.value;
const timestamp = new Date(item.timestamp).getTime();
// Check if the current item matches the nodeId and is the latest
if (currentNodeId === nodeId && timestamp > latestTimestamp) {
latestTimestamp = timestamp;
latestItem = item;
}
});
// Return the first field value if a matching latest item is found
if (latestItem && latestItem.fields.length > 0) {
return latestItem.fields[0].value.float.value;
}
return null;
}
function changeDatePicker(value) {
dateRange.value = value;
fetchNodes();
}
// watch update to auto fresh
let intervalId;
watchEffect(() => {
if (intervalId) clearInterval(intervalId);
fetchNodes();
if (autoRefresh.value !== 'off') {
intervalId = setInterval(() => {
const currentStart = dateRange.value[0];
const currentEnd = dateRange.value[1];
const newEnd = new Date(currentEnd.getTime() + autoRefresh.value);
const newStart = new Date(currentStart.getTime() + autoRefresh.value);
dateRange.value = [newStart, newEnd];
fetchNodes();
}, autoRefresh.value);
}
});
</script>
<template>
<div class="dashboard">
<div class="header-container">
<span class="timestamp">
<el-date-picker
@change="changeDatePicker"
v-model="dateRange"
type="datetimerange"
:shortcuts="Shortcuts"
range-separator="to"
start-placeholder="begin"
end-placeholder="end"
align="right"
style="margin: 0 10px"
></el-date-picker>
<span class="timestamp-item">{{ timezoneOffset }}</span>
</span>
<span class="autofresh">
<span class="timestamp-item">Auto Fresh:</span>
<el-select v-model="autoRefresh" placeholder="Select" class="auto-fresh-select">
<el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</span>
</div>
<div class="error-alert">
<!-- Conditionally display the alert if hasMonitoring is false -->
<el-alert
v-if="!hasMonitoring"
title='Self-monitoring not available, please turn it on by setting "--observability-modes=native".'
type="error"
center
show-icon
:closable="false"
/>
</div>
<el-card shadow="always">
<template #header>
<div class="card-header">
<span>Nodes</span>
</div>
</template>
<div class="table-container">
<el-table
v-loading="nodes.loading"
element-loading-text="loading"
element-loading-spinner="el-icon-loading"
element-loading-background="rgba(0, 0, 0, 0.8)"
stripe
border
highlight-current-row
tooltip-effect="dark"
empty-text="No data yet"
:data="nodes"
:table-layout="tableLayout"
>
<el-table-column prop="node_id" label="Node ID"></el-table-column>
<el-table-column prop="node_type" label="Type"></el-table-column>
<el-table-column prop="uptime" label="Uptime"></el-table-column>
<el-table-column label="CPU">
<template #default="scope">
<el-progress
type="dashboard"
:percentage="parseFloat((scope.row.cpu * 100).toFixed(2))"
:color="colors"
/>
</template>
</el-table-column>
<el-table-column label="Memory">
<template #default="scope">
<div class="memory-detail">
<div class="progress-container">
<el-progress
type="line"
:percentage="parseFloat((scope.row.memory.used_percent * 100).toFixed(2))"
:color="colors"
:stroke-width="6"
:show-text="true"
class="fixed-progress-bar"
/>
</div>
<div class="memory-stats">
<span>Used: {{ formatBytes(scope.row.memory.used) }}</span>
<span>Total: {{ formatBytes(scope.row.memory.total) }}</span>
<span>
Free:
{{
scope.row.memory.total && scope.row.memory.used
? formatBytes(scope.row.memory.total - scope.row.memory.used)
: 'N/A'
}}
</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="Disk Details">
<template #default="scope">
<div v-if="!scope.row.disk"> N/A </div>
<div class="disk-detail" v-else v-for="(value, key) in scope.row.disk" :key="key">
<div class="progress-container">
<span v-if="isTruncated(key)" class="disk-key">
<el-tooltip
class="box-item"
effect="light"
:content="key"
placement="top"
:popper-class="'custom-tooltip'"
>
<span>{{ truncatePath(key) }}:</span>
</el-tooltip>
</span>
<span v-else class="disk-key">{{ key }}:</span>
</div>
<div class="progress-container">
<el-progress
type="line"
:percentage="parseFloat((value.used_percent * 100).toFixed(2))"
:color="colors"
:stroke-width="6"
:show-text="true"
class="fixed-progress-bar"
/>
</div>
<div class="disk-stats">
<span>Used: {{ formatBytes(value.used) }}</span>
<span>Total: {{ formatBytes(value.total) }}</span>
<span> Free: {{ value.total && value.used ? formatBytes(value.total - value.used) : 'N/A' }} </span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="Port">
<template #default="scope">
<div>
<div>gRPC: {{ scope.row.grpc_address }}</div>
<div>HTTP: {{ scope.row.http_address || 'N/A' }}</div>
</div>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</div>
</template>
<style lang="scss" scoped>
.dashboard {
position: relative;
}
.error-alert {
margin: 20px 15px 5px 15px;
}
.header-container {
display: flex;
align-items: center;
justify-content: flex-end;
margin: 15px 15px 10px 15px;
position: sticky;
top: 0;
z-index: 1000;
padding: 10px;
background-color: inherit;
}
@media (max-width: 900px) {
.header-container {
flex-direction: column;
align-items: flex-end;
}
.timestamp,
.autofresh {
margin-bottom: 10px;
}
.autofresh {
display: flex;
align-items: center;
}
.timestamp-item {
margin-right: 5px;
}
}
.timestamp {
font-size: 16px;
color: #666;
}
.timestamp-item {
margin-right: 12px;
}
.auto-fresh-select {
width: 200px;
}
.card-header {
font-size: 20px;
height: 10px;
}
.header-text {
padding: 0;
margin: 0;
hr {
margin: 0;
border-top: 1px solid grey;
}
}
.fixed-progress-bar {
width: 65%;
min-width: 150px;
}
.table-container {
max-height: 625px;
overflow-y: auto;
}
.memory-detail,
.disk-detail {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-bottom: 20px;
}
.disk-key {
margin-right: 10px;
color: #606266;
}
.progress-container,
.memory-stats,
.disk-stats {
display: flex;
justify-content: flex-start;
text-align: left;
width: 100%;
gap: 10px;
padding-top: 6px;
}
@media (max-width: 1200px) {
.disk-key,
.memory-stats,
.disk-stats {
display: none;
}
.fixed-progress-bar {
width: 80%;
}
}
</style>