mirror of
https://github.com/akvorado/akvorado.git
synced 2025-12-12 06:24:10 +01:00
console/frontend: add a graph on the homepage
This commit is contained in:
85
console/frontend/src/components/WidgetGraph.vue
Normal file
85
console/frontend/src/components/WidgetGraph.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="h-[300px]">
|
||||
<v-chart :option="option" autoresize />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import { use, graphic } from "echarts/core";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import { LineChart } from "echarts/charts";
|
||||
import { TooltipComponent, GridComponent } from "echarts/components";
|
||||
import VChart from "vue-echarts";
|
||||
|
||||
use([CanvasRenderer, LineChart, TooltipComponent, GridComponent]);
|
||||
|
||||
const formatGbps = (value) => {
|
||||
const suffixes = ["", "K", "M", "G", "T"];
|
||||
let idx = 0;
|
||||
value *= 1000 * 1000 * 1000;
|
||||
while (value >= 1000 && idx < suffixes.length) {
|
||||
value /= 1000;
|
||||
idx++;
|
||||
}
|
||||
value = value.toFixed(2);
|
||||
return `${value}${suffixes[idx]}`;
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
refresh: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
const option = ref({
|
||||
xAxis: { type: "time" },
|
||||
yAxis: {
|
||||
type: "value",
|
||||
min: 0,
|
||||
axisLabel: { formatter: formatGbps },
|
||||
},
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: {
|
||||
type: "cross",
|
||||
label: { backgroundColor: "#6a7985" },
|
||||
},
|
||||
valueFormatter: formatGbps,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: "line",
|
||||
symbol: "none",
|
||||
lineStyle: {
|
||||
color: "#5470c6",
|
||||
width: 1,
|
||||
},
|
||||
areaStyle: {
|
||||
opacity: 1,
|
||||
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: "#5470c6" },
|
||||
{ offset: 1, color: "#5572c8" },
|
||||
]),
|
||||
},
|
||||
data: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.refresh,
|
||||
async () => {
|
||||
const response = await fetch("/api/v0/console/widget/graph?width=150");
|
||||
if (!response.ok) {
|
||||
// Keep current data
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
option.value.series[0].data = data.data.map(({ t, gbps }) => [t, gbps]);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
@@ -60,7 +60,6 @@ const option = ref({
|
||||
if (name === "Others") {
|
||||
return "#aaa";
|
||||
}
|
||||
console.log(option.value);
|
||||
return ["#5470c6", "#91cc75", "#fac858", "#ee6666", "#73c0de"][
|
||||
dataIndex % 5
|
||||
];
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
title="Top countries"
|
||||
:refresh="refreshOccasionally"
|
||||
/>
|
||||
<WidgetGraph
|
||||
:refresh="refreshInfrequently"
|
||||
class="col-span-2 md:col-span-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -49,13 +53,17 @@ import WidgetLastFlow from "../components/WidgetLastFlow.vue";
|
||||
import WidgetFlowRate from "../components/WidgetFlowRate.vue";
|
||||
import WidgetExporters from "../components/WidgetExporters.vue";
|
||||
import WidgetTop from "../components/WidgetTop.vue";
|
||||
import WidgetGraph from "../components/WidgetGraph.vue";
|
||||
|
||||
const refreshOften = ref(0);
|
||||
const refreshOccasionally = ref(0);
|
||||
const refreshInfrequently = ref(0);
|
||||
let timerOften = setInterval(() => refreshOften.value++, 10_000);
|
||||
let timerOccasionally = setInterval(() => refreshOccasionally.value++, 60_000);
|
||||
let timerInfrequently = setInterval(() => refreshInfrequently.value++, 600_000);
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(timerOften);
|
||||
clearInterval(timerOccasionally);
|
||||
clearInterval(timerInfrequently);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -59,6 +59,7 @@ func (c *Component) Start() error {
|
||||
c.d.HTTP.GinRouter.GET("/api/v0/console/widget/flow-rate", c.widgetFlowRateHandlerFunc)
|
||||
c.d.HTTP.GinRouter.GET("/api/v0/console/widget/exporters", c.widgetExportersHandlerFunc)
|
||||
c.d.HTTP.GinRouter.GET("/api/v0/console/widget/top/:name", c.widgetTopHandlerFunc)
|
||||
c.d.HTTP.GinRouter.GET("/api/v0/console/widget/graph", c.widgetGraphHandlerFunc)
|
||||
|
||||
c.t.Go(func() error {
|
||||
select {
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -156,3 +158,41 @@ LIMIT 5
|
||||
}
|
||||
gc.JSON(http.StatusOK, gin.H{"top": results})
|
||||
}
|
||||
|
||||
func (c *Component) widgetGraphHandlerFunc(gc *gin.Context) {
|
||||
ctx := c.t.Context(gc.Request.Context())
|
||||
|
||||
width, err := strconv.ParseUint(gc.DefaultQuery("width", "500"), 10, 16)
|
||||
if err != nil {
|
||||
c.r.Err(err).Msg("invalid width parameter")
|
||||
gc.JSON(http.StatusBadRequest, gin.H{"message": "Invalid width value."})
|
||||
return
|
||||
}
|
||||
if width < 5 || width > 1000 {
|
||||
gc.JSON(http.StatusBadRequest, gin.H{"message": "Width should be > 5 and < 1000"})
|
||||
return
|
||||
}
|
||||
interval := uint64((24 * time.Hour).Seconds()) / width
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
toStartOfInterval(TimeReceived, INTERVAL %d second) AS Time,
|
||||
SUM(Bytes*SamplingRate*8/%d)/1000/1000/1000 AS Gbps
|
||||
FROM flows
|
||||
WHERE TimeReceived > date_sub(hour, 24, now())
|
||||
AND InIfBoundary = 'external'
|
||||
GROUP BY Time
|
||||
ORDER BY Time`, interval, interval)
|
||||
|
||||
results := []struct {
|
||||
Time time.Time `json:"t"`
|
||||
Gbps float64 `json:"gbps"`
|
||||
}{}
|
||||
err = c.d.ClickHouseDB.Conn.Select(ctx, &results, query)
|
||||
if err != nil {
|
||||
c.r.Err(err).Msg("unable to query database")
|
||||
gc.JSON(http.StatusInternalServerError, gin.H{"message": "Unable to query database."})
|
||||
return
|
||||
}
|
||||
|
||||
gc.JSON(http.StatusOK, gin.H{"data": results})
|
||||
}
|
||||
|
||||
@@ -185,7 +185,6 @@ func TestWidgetExporters(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func TestWidgetTop(t *testing.T) {
|
||||
@@ -261,3 +260,54 @@ func TestWidgetTop(t *testing.T) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestWidgetGraph(t *testing.T) {
|
||||
r := reporter.NewMock(t)
|
||||
ch, mockConn := clickhousedb.NewMock(t, r)
|
||||
h := http.NewMock(t, r)
|
||||
c, err := New(r, Configuration{}, Dependencies{
|
||||
Daemon: daemon.NewMock(t),
|
||||
HTTP: h,
|
||||
ClickHouseDB: ch,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error:\n%+v", err)
|
||||
}
|
||||
helpers.StartStop(t, c)
|
||||
|
||||
base := time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC)
|
||||
expected := []struct {
|
||||
Time time.Time `json:"t"`
|
||||
Gbps float64 `json:"gbps"`
|
||||
}{
|
||||
{base, 25.3},
|
||||
{base.Add(time.Minute), 27.8},
|
||||
{base.Add(2 * time.Minute), 26.4},
|
||||
{base.Add(3 * time.Minute), 29.2},
|
||||
{base.Add(4 * time.Minute), 21.3},
|
||||
{base.Add(5 * time.Minute), 24.7},
|
||||
}
|
||||
mockConn.EXPECT().
|
||||
Select(gomock.Any(), gomock.Any(), `
|
||||
SELECT
|
||||
toStartOfInterval(TimeReceived, INTERVAL 864 second) AS Time,
|
||||
SUM(Bytes*SamplingRate*8/864)/1000/1000/1000 AS Gbps
|
||||
FROM flows
|
||||
WHERE TimeReceived > date_sub(hour, 24, now())
|
||||
AND InIfBoundary = 'external'
|
||||
GROUP BY Time
|
||||
ORDER BY Time`).
|
||||
SetArg(1, expected).
|
||||
Return(nil)
|
||||
|
||||
helpers.TestHTTPEndpoints(t, h.Address, helpers.HTTPEndpointCases{
|
||||
{
|
||||
URL: "/api/v0/console/widget/graph?width=100",
|
||||
ContentType: "application/json; charset=utf-8",
|
||||
FirstLines: []string{
|
||||
`{"data":[{"t":"2009-11-10T23:00:00Z","gbps":25.3},{"t":"2009-11-10T23:01:00Z","gbps":27.8},{"t":"2009-11-10T23:02:00Z","gbps":26.4},{"t":"2009-11-10T23:03:00Z","gbps":29.2},{"t":"2009-11-10T23:04:00Z","gbps":21.3},{"t":"2009-11-10T23:05:00Z","gbps":24.7}]}`,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user