Introduce checker returning metrics
This commit is contained in:
parent
74eab11ba5
commit
0991df069a
19 changed files with 577 additions and 20 deletions
|
|
@ -82,3 +82,14 @@ func GetHTMLReport(checker happydns.Checker, raw json.RawMessage) (string, bool,
|
|||
html, err := hr.GetHTMLReport(raw)
|
||||
return html, true, err
|
||||
}
|
||||
|
||||
// GetMetrics extracts time-series metrics from a slice of check results.
|
||||
// Returns (report, true, nil) if the checker supports metrics, or (nil, false, nil) if not.
|
||||
func GetMetrics(checker happydns.Checker, results []*happydns.CheckResult) (*happydns.MetricsReport, bool, error) {
|
||||
mr, ok := checker.(happydns.CheckerMetricsReporter)
|
||||
if !ok {
|
||||
return nil, false, nil
|
||||
}
|
||||
report, err := mr.ExtractMetrics(results)
|
||||
return report, true, err
|
||||
}
|
||||
|
|
|
|||
1
go.mod
1
go.mod
|
|
@ -21,6 +21,7 @@ require (
|
|||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/oracle/nosql-go-sdk v1.4.7
|
||||
github.com/ovh/go-ovh v1.9.0
|
||||
github.com/prometheus-community/pro-bing v0.8.0
|
||||
github.com/rrivera/identicon v0.0.0-20240116195454-d5ba35832c0d
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.1
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -509,6 +509,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
|||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc=
|
||||
github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
|
|
|
|||
|
|
@ -80,6 +80,16 @@ func getDomainAndServiceIDFromContext(c *gin.Context) (domainID *happydns.Identi
|
|||
// GetCheckerOptionsWithScope retrieves all options for a check with the given scope.
|
||||
func (bc *BaseCheckerController) GetCheckerOptionsWithScope(c *gin.Context, cname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) {
|
||||
opts, err := bc.checkerService.GetCheckerOptions(cname, userId, domainId, serviceId)
|
||||
|
||||
// For non-basic type, stringify
|
||||
if opts != nil {
|
||||
for i, opt := range *opts {
|
||||
if svc, ok := opt.(*happydns.ServiceMessage); ok {
|
||||
(*opts)[i] = svc.Type + ": " + svc.Comment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
happydns.ApiResponse(c, opts, err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -432,6 +432,122 @@ func (tc *CheckResultController) GetCheckResultHTMLReport(c *gin.Context) {
|
|||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(htmlContent))
|
||||
}
|
||||
|
||||
// GetCheckResultMetrics returns time-series metrics extracted from check results
|
||||
//
|
||||
// @Summary Get check result metrics
|
||||
// @Description Returns time-series metrics suitable for charting, extracted from recent check results. Only available for checkers that implement metrics reporting.
|
||||
// @Tags checks
|
||||
// @Produce json
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string false "Zone identifier"
|
||||
// @Param subdomain path string false "Subdomain"
|
||||
// @Param serviceid path string false "Service identifier"
|
||||
// @Param cname path string true "Check plugin name"
|
||||
// @Param limit query int false "Maximum number of results to extract metrics from (default: 100)"
|
||||
// @Success 200 {object} happydns.MetricsReport
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Failure 500 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checks/{cname}/metrics [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/metrics [get]
|
||||
func (tc *CheckResultController) GetCheckResultMetrics(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
checkName := c.Param("cname")
|
||||
targetID, err := tc.getTargetFromContext(c)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
limit := 100
|
||||
if limitStr := c.Query("limit"); limitStr != "" {
|
||||
fmt.Sscanf(limitStr, "%d", &limit)
|
||||
}
|
||||
|
||||
checker, err := tc.checkerUC.GetChecker(checkName)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
|
||||
insideScope, insideID := tc.getInsideFromContext(c)
|
||||
results, err := tc.checkResultUC.ListCheckResultsByTarget(checkName, tc.scope, targetID, insideScope, insideID, user.Id, limit)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
report, supported, err := checks.GetMetrics(checker, results)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if !supported {
|
||||
middleware.ErrorResponse(c, http.StatusNotFound, fmt.Errorf("checker %q does not support metrics", checkName))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, report)
|
||||
}
|
||||
|
||||
// GetSingleCheckResultMetrics returns metrics extracted from a single check result
|
||||
//
|
||||
// @Summary Get single check result metrics
|
||||
// @Description Returns metrics extracted from a single check result. Only available for checkers that implement metrics reporting.
|
||||
// @Tags checks
|
||||
// @Produce json
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param zoneid path string false "Zone identifier"
|
||||
// @Param subdomain path string false "Subdomain"
|
||||
// @Param serviceid path string false "Service identifier"
|
||||
// @Param cname path string true "Check plugin name"
|
||||
// @Param result_id path string true "Result ID"
|
||||
// @Success 200 {object} happydns.MetricsReport
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Failure 500 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checks/{cname}/results/{result_id}/metrics [get]
|
||||
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/results/{result_id}/metrics [get]
|
||||
func (tc *CheckResultController) GetSingleCheckResultMetrics(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
checkName := c.Param("cname")
|
||||
resultIDStr := c.Param("result_id")
|
||||
targetID, err := tc.getTargetFromContext(c)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
resultID, err := happydns.NewIdentifierFromString(resultIDStr)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid result ID"))
|
||||
return
|
||||
}
|
||||
|
||||
insideScope, insideID := tc.getInsideFromContext(c)
|
||||
result, err := tc.checkResultUC.GetCheckResult(checkName, tc.scope, targetID, resultID, insideScope, insideID, user.Id)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
|
||||
checker, err := tc.checkerUC.GetChecker(checkName)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
|
||||
report, supported, err := checks.GetMetrics(checker, []*happydns.CheckResult{result})
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if !supported {
|
||||
middleware.ErrorResponse(c, http.StatusNotFound, fmt.Errorf("checker %q does not support metrics", checkName))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, report)
|
||||
}
|
||||
|
||||
// DropCheckResult deletes a specific check result
|
||||
//
|
||||
// @Summary Delete check result
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ import (
|
|||
|
||||
// DeclareScopedCheckResultRoutes declares test result routes for a specific scope (domain, zone, or service)
|
||||
func DeclareScopedCheckResultRoutes(apiChecksRoutes *gin.RouterGroup, tc *controller.CheckResultController) {
|
||||
// Check metrics route
|
||||
apiChecksRoutes.GET("/metrics", tc.GetCheckResultMetrics)
|
||||
|
||||
// Check results routes
|
||||
apiChecksRoutes.GET("/results", tc.ListCheckResults)
|
||||
apiChecksRoutes.DELETE("/results", tc.DropCheckResults)
|
||||
|
|
@ -38,5 +41,6 @@ func DeclareScopedCheckResultRoutes(apiChecksRoutes *gin.RouterGroup, tc *contro
|
|||
apiCheckResultsRoutes.GET("", tc.GetCheckResult)
|
||||
apiCheckResultsRoutes.DELETE("", tc.DropCheckResult)
|
||||
apiCheckResultsRoutes.GET("/report", tc.GetCheckResultHTMLReport)
|
||||
apiCheckResultsRoutes.GET("/metrics", tc.GetSingleCheckResultMetrics)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,6 +95,34 @@ type CheckerHTMLReporter interface {
|
|||
GetHTMLReport(raw json.RawMessage) (string, error)
|
||||
}
|
||||
|
||||
// MetricPoint represents a single data point in a time series.
|
||||
type MetricPoint struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Value float64 `json:"value"`
|
||||
}
|
||||
|
||||
// MetricSeries represents a named time series with a display label and unit.
|
||||
type MetricSeries struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
Unit string `json:"unit"`
|
||||
Points []MetricPoint `json:"points"`
|
||||
}
|
||||
|
||||
// MetricsReport contains all metric series extracted from check results.
|
||||
type MetricsReport struct {
|
||||
Series []MetricSeries `json:"series"`
|
||||
}
|
||||
|
||||
// CheckerMetricsReporter is an optional interface checkers can implement
|
||||
// to signal that they produce time-series metrics suitable for charting.
|
||||
// Detect support with a type assertion: _, ok := checker.(CheckerMetricsReporter)
|
||||
type CheckerMetricsReporter interface {
|
||||
// ExtractMetrics builds time series from a slice of check results.
|
||||
// Each result's ExecutedAt becomes the timestamp for that data point.
|
||||
ExtractMetrics(results []*CheckResult) (*MetricsReport, error)
|
||||
}
|
||||
|
||||
type CheckerResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
|
|
@ -102,6 +130,7 @@ type CheckerResponse struct {
|
|||
Options CheckerOptionsDocumentation `json:"options"`
|
||||
Interval *CheckIntervalSpec `json:"interval"`
|
||||
HasHTMLReport bool `json:"has_html_report,omitempty"`
|
||||
HasMetrics bool `json:"has_metrics,omitempty"`
|
||||
}
|
||||
|
||||
type SetCheckerOptionsRequest struct {
|
||||
|
|
|
|||
43
web/package-lock.json
generated
43
web/package-lock.json
generated
|
|
@ -13,6 +13,9 @@
|
|||
"@sveltestrap/sveltestrap": "^7.0.0",
|
||||
"bootstrap": "^5.3.0",
|
||||
"bootstrap-icons": "^1.13.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"html-escaper": "^3.0.0",
|
||||
"sass": "^1.97.0",
|
||||
|
|
@ -864,6 +867,12 @@
|
|||
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||
|
|
@ -2374,6 +2383,29 @@
|
|||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chartjs-adapter-date-fns": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
|
||||
"integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"chart.js": ">=2.8.0",
|
||||
"date-fns": ">=2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
|
|
@ -2512,6 +2544,17 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@
|
|||
"@sveltestrap/sveltestrap": "^7.0.0",
|
||||
"bootstrap": "^5.3.0",
|
||||
"bootstrap-icons": "^1.13.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"html-escaper": "^3.0.0",
|
||||
"sass": "^1.97.0",
|
||||
|
|
|
|||
|
|
@ -51,6 +51,10 @@ import {
|
|||
deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResultsByResultId,
|
||||
deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResults,
|
||||
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResultsByResultIdReport,
|
||||
getDomainsByDomainChecksByCnameMetrics,
|
||||
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameMetrics,
|
||||
getDomainsByDomainChecksByCnameResultsByResultIdMetrics,
|
||||
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResultsByResultIdMetrics,
|
||||
} from "$lib/api-base/sdk.gen";
|
||||
import { unwrapSdkResponse } from "./errors";
|
||||
import type {
|
||||
|
|
@ -62,6 +66,7 @@ import type {
|
|||
CheckResult,
|
||||
CheckExecution,
|
||||
CheckScopeType,
|
||||
MetricsReport,
|
||||
} from "$lib/model/checker";
|
||||
|
||||
export async function listCheckers(): Promise<CheckerList> {
|
||||
|
|
@ -502,3 +507,76 @@ export async function getServiceCheckResultHTMLReport(
|
|||
),
|
||||
) as string;
|
||||
}
|
||||
|
||||
// --- Metrics API functions ---
|
||||
|
||||
export async function getCheckMetrics(
|
||||
domainId: string,
|
||||
checkName: string,
|
||||
limit: number = 100,
|
||||
): Promise<MetricsReport> {
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainChecksByCnameMetrics({
|
||||
path: { domain: domainId, cname: checkName },
|
||||
query: { limit },
|
||||
}),
|
||||
) as unknown as MetricsReport;
|
||||
}
|
||||
|
||||
export async function getServiceCheckMetrics(
|
||||
domainId: string,
|
||||
zoneId: string,
|
||||
subdomain: string,
|
||||
serviceid: string,
|
||||
checkName: string,
|
||||
limit: number = 100,
|
||||
): Promise<MetricsReport> {
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameMetrics({
|
||||
path: {
|
||||
domain: domainId,
|
||||
zoneid: zoneId,
|
||||
subdomain,
|
||||
serviceid,
|
||||
cname: checkName,
|
||||
} as any,
|
||||
query: { limit },
|
||||
}),
|
||||
) as unknown as MetricsReport;
|
||||
}
|
||||
|
||||
export async function getCheckResultMetrics(
|
||||
domainId: string,
|
||||
checkName: string,
|
||||
resultId: string,
|
||||
): Promise<MetricsReport> {
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainChecksByCnameResultsByResultIdMetrics({
|
||||
path: { domain: domainId, cname: checkName, result_id: resultId },
|
||||
}),
|
||||
) as unknown as MetricsReport;
|
||||
}
|
||||
|
||||
export async function getServiceCheckResultMetrics(
|
||||
domainId: string,
|
||||
zoneId: string,
|
||||
subdomain: string,
|
||||
serviceid: string,
|
||||
checkName: string,
|
||||
resultId: string,
|
||||
): Promise<MetricsReport> {
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResultsByResultIdMetrics(
|
||||
{
|
||||
path: {
|
||||
domain: domainId,
|
||||
zoneid: zoneId,
|
||||
subdomain,
|
||||
serviceid,
|
||||
cname: checkName,
|
||||
result_id: resultId,
|
||||
} as any,
|
||||
},
|
||||
),
|
||||
) as unknown as MetricsReport;
|
||||
}
|
||||
|
|
|
|||
158
web/src/lib/components/checkers/CheckMetricsChart.svelte
Normal file
158
web/src/lib/components/checkers/CheckMetricsChart.svelte
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import {
|
||||
Chart,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
Legend,
|
||||
Tooltip,
|
||||
Filler,
|
||||
} from "chart.js";
|
||||
import "chartjs-adapter-date-fns";
|
||||
import type { MetricsReport } from "$lib/model/checker";
|
||||
|
||||
Chart.register(
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
Legend,
|
||||
Tooltip,
|
||||
Filler,
|
||||
);
|
||||
|
||||
interface Props {
|
||||
report: MetricsReport;
|
||||
}
|
||||
|
||||
let { report }: Props = $props();
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
let chart: Chart | null = null;
|
||||
|
||||
const COLORS = [
|
||||
"#0d6efd",
|
||||
"#dc3545",
|
||||
"#198754",
|
||||
"#ffc107",
|
||||
"#6610f2",
|
||||
"#0dcaf0",
|
||||
"#fd7e14",
|
||||
"#d63384",
|
||||
];
|
||||
|
||||
function buildChart() {
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
chart = null;
|
||||
}
|
||||
if (!canvas || !report?.series?.length) return;
|
||||
|
||||
const units = [...new Set(report.series.map((s) => s.unit))];
|
||||
const hasRightAxis = units.length > 1;
|
||||
const rightUnit = hasRightAxis ? units[1] : null;
|
||||
|
||||
const datasets = report.series.map((series, i) => ({
|
||||
label: series.label,
|
||||
data: series.points.map((p) => ({
|
||||
x: new Date(p.timestamp).getTime(),
|
||||
y: p.value,
|
||||
})),
|
||||
borderColor: COLORS[i % COLORS.length],
|
||||
backgroundColor: COLORS[i % COLORS.length] + "20",
|
||||
borderWidth: 2,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
tension: 0.3,
|
||||
yAxisID: hasRightAxis && series.unit === rightUnit ? "y1" : "y",
|
||||
}));
|
||||
|
||||
const scales: Record<string, any> = {
|
||||
x: {
|
||||
type: "time" as const,
|
||||
time: { tooltipFormat: "PPpp" },
|
||||
title: { display: false },
|
||||
},
|
||||
y: {
|
||||
type: "linear" as const,
|
||||
position: "left" as const,
|
||||
title: { display: true, text: units[0] || "" },
|
||||
beginAtZero: true,
|
||||
},
|
||||
};
|
||||
|
||||
if (hasRightAxis && rightUnit) {
|
||||
scales.y1 = {
|
||||
type: "linear" as const,
|
||||
position: "right" as const,
|
||||
title: { display: true, text: rightUnit },
|
||||
beginAtZero: true,
|
||||
grid: { drawOnChartArea: false },
|
||||
};
|
||||
}
|
||||
|
||||
chart = new Chart(canvas, {
|
||||
type: "line",
|
||||
data: { datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: "index", intersect: false },
|
||||
scales,
|
||||
plugins: {
|
||||
legend: { position: "bottom" },
|
||||
tooltip: { mode: "index", intersect: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
buildChart();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Re-build chart when report changes
|
||||
if (report && canvas) {
|
||||
buildChart();
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
chart = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="chart-container" style="position: relative; height: 350px; width: 100%;">
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
</div>
|
||||
|
|
@ -39,7 +39,7 @@
|
|||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
// Store imports
|
||||
import { currentCheckResult, currentCheckInfo, showHTMLReport } from "$lib/stores/checkers";
|
||||
import { currentCheckResult, currentCheckInfo, showHTMLReport, reportViewMode } from "$lib/stores/checkers";
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
|
||||
// API imports
|
||||
|
|
@ -267,25 +267,39 @@
|
|||
|
||||
<div class="my-3 flex-fill"></div>
|
||||
|
||||
{#if $currentCheckInfo?.has_html_report || $currentCheckResult.report != null}
|
||||
{#if $currentCheckInfo?.has_html_report}
|
||||
{#if $currentCheckInfo?.has_html_report || $currentCheckInfo?.has_metrics || $currentCheckResult.report != null}
|
||||
{#if $currentCheckInfo?.has_metrics || $currentCheckInfo?.has_html_report}
|
||||
<ButtonGroup class="w-100 mb-2">
|
||||
{#if $currentCheckInfo?.has_metrics}
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
outline
|
||||
active={$reportViewMode === "metrics"}
|
||||
onclick={() => { reportViewMode.set("metrics"); showHTMLReport.set(false); }}
|
||||
>
|
||||
<Icon name="graph-up"></Icon>
|
||||
{$t("checkers.result.view-metrics")}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if $currentCheckInfo?.has_html_report}
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
outline
|
||||
active={$reportViewMode === "html" || (!$currentCheckInfo?.has_metrics && $showHTMLReport)}
|
||||
onclick={() => { reportViewMode.set("html"); showHTMLReport.set(true); }}
|
||||
>
|
||||
<Icon name="file-earmark-richtext"></Icon>
|
||||
{$t("checkers.result.view-html")}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
outline
|
||||
active={$showHTMLReport}
|
||||
onclick={() => showHTMLReport.set(true)}
|
||||
>
|
||||
<Icon name="file-earmark-richtext"></Icon>
|
||||
{$t("checkers.result.view-html")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
outline
|
||||
active={!$showHTMLReport}
|
||||
onclick={() => showHTMLReport.set(false)}
|
||||
active={$reportViewMode === "json" || (!$currentCheckInfo?.has_metrics && !$currentCheckInfo?.has_html_report)}
|
||||
onclick={() => { reportViewMode.set("json"); showHTMLReport.set(false); }}
|
||||
>
|
||||
<Icon name="braces"></Icon>
|
||||
{$t("checkers.result.view-json")}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
import { onDestroy } from "svelte";
|
||||
import { t } from "$lib/translations";
|
||||
import type { CheckerInfo, CheckResult } from "$lib/model/checker";
|
||||
import type { CheckerInfo, CheckResult, MetricsReport } from "$lib/model/checker";
|
||||
import {
|
||||
currentCheckResult,
|
||||
currentCheckInfo,
|
||||
|
|
@ -38,14 +38,30 @@
|
|||
resultPromise: Promise<CheckResult>;
|
||||
checkPromise: Promise<CheckerInfo>;
|
||||
htmlReportPromise: Promise<string>;
|
||||
getMetrics: () => Promise<MetricsReport>;
|
||||
}
|
||||
|
||||
let { resultPromise, checkPromise, htmlReportPromise }: Props = $props();
|
||||
let { resultPromise, checkPromise, htmlReportPromise, getMetrics }: Props = $props();
|
||||
|
||||
let metricsReport = $state<MetricsReport | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
resultPromise.then((r) => currentCheckResult.set(r));
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
metricsReport = null;
|
||||
checkPromise.then((c) => {
|
||||
currentCheckInfo.set(c);
|
||||
if (c.has_metrics) {
|
||||
reportViewMode.set("metrics");
|
||||
getMetrics()
|
||||
.then((r) => (metricsReport = r))
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
currentCheckResult.set(null);
|
||||
currentCheckInfo.set(null);
|
||||
|
|
@ -62,7 +78,34 @@
|
|||
</div>
|
||||
{:then [result, check]}
|
||||
{#if result.report || check.has_html_report || check.has_metrics}
|
||||
{#if check.has_html_report && ($reportViewMode === "html" || ($showHTMLReport && $reportViewMode !== "json"))}
|
||||
{#if check.has_metrics && $reportViewMode === "metrics"}
|
||||
<div class="p-3 flex-fill">
|
||||
{#if metricsReport}
|
||||
<Table size="sm" hover striped>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Metric</th>
|
||||
<th class="text-end">Value</th>
|
||||
<th>Unit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each metricsReport.series as series}
|
||||
{#each series.points as point}
|
||||
<tr>
|
||||
<td>{series.label}</td>
|
||||
<td class="text-end font-monospace">{point.value}</td>
|
||||
<td class="text-muted">{series.unit}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/each}
|
||||
</tbody>
|
||||
</Table>
|
||||
{:else}
|
||||
<div class="text-center p-4"><Spinner /></div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if check.has_html_report && ($reportViewMode === "html" || ($showHTMLReport && $reportViewMode !== "json"))}
|
||||
{#await htmlReportPromise}
|
||||
<div class="text-center p-4"><Spinner /></div>
|
||||
{:then html}
|
||||
|
|
|
|||
|
|
@ -39,9 +39,10 @@
|
|||
import { getCheckStatus } from "$lib/api/checkers";
|
||||
import PageTitle from "$lib/components/PageTitle.svelte";
|
||||
import type { Domain } from "$lib/model/domain";
|
||||
import type { CheckExecution, CheckResult } from "$lib/model/checker";
|
||||
import type { CheckExecution, CheckResult, MetricsReport } from "$lib/model/checker";
|
||||
import { CheckExecutionStatus, CheckScopeType } from "$lib/model/checker";
|
||||
import RunCheckModal from "$lib/components/modals/RunCheckModal.svelte";
|
||||
import CheckMetricsChart from "$lib/components/checkers/CheckMetricsChart.svelte";
|
||||
import { getStatusColor, getStatusKey, formatDuration, formatCheckDate } from "$lib/utils";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -55,6 +56,7 @@
|
|||
getExecution: (executionId: string) => Promise<CheckExecution>;
|
||||
deleteResult: (resultId: string) => Promise<void>;
|
||||
deleteAllResults: () => Promise<void>;
|
||||
loadMetrics?: () => Promise<MetricsReport>;
|
||||
zoneId?: string;
|
||||
subdomain?: string;
|
||||
serviceid?: string;
|
||||
|
|
@ -71,6 +73,7 @@
|
|||
getExecution,
|
||||
deleteResult,
|
||||
deleteAllResults,
|
||||
loadMetrics,
|
||||
zoneId,
|
||||
subdomain,
|
||||
serviceid,
|
||||
|
|
@ -79,8 +82,18 @@
|
|||
let resultsPromise = $state(loadResults());
|
||||
let checkerPromise = $derived(getCheckStatus(checkerName));
|
||||
let checkerDisplayName = $state(checkerName);
|
||||
let metricsReport = $state<MetricsReport | null>(null);
|
||||
$effect(() => {
|
||||
checkerPromise.then((c) => (checkerDisplayName = c.name || checkerName)).catch(() => {});
|
||||
checkerPromise
|
||||
.then((c) => {
|
||||
checkerDisplayName = c.name || checkerName;
|
||||
if (c.has_metrics && loadMetrics) {
|
||||
loadMetrics()
|
||||
.then((r) => (metricsReport = r))
|
||||
.catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
let runCheckModal: RunCheckModal;
|
||||
let errorMessage = $state<string | null>(null);
|
||||
|
|
@ -193,6 +206,13 @@
|
|||
<p>{$t("checkers.results.loading")}</p>
|
||||
</div>
|
||||
{:then results}
|
||||
{#if metricsReport}
|
||||
<Card class="mb-3">
|
||||
<div class="card-body">
|
||||
<CheckMetricsChart report={metricsReport} />
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
{#if (!results || results.length === 0) && pendingExecutions.length === 0}
|
||||
<Card body>
|
||||
<p class="text-center text-muted mb-0">
|
||||
|
|
|
|||
|
|
@ -725,6 +725,7 @@
|
|||
"status-message": "Message:",
|
||||
"error": "Error:"
|
||||
},
|
||||
"view-metrics": "Metrics",
|
||||
"view-html": "HTML Report",
|
||||
"view-json": "Raw JSON",
|
||||
"download-html": "Download HTML",
|
||||
|
|
|
|||
|
|
@ -69,6 +69,22 @@ export enum CheckExecutionStatus {
|
|||
CheckExecutionFailed = 3,
|
||||
}
|
||||
|
||||
export interface MetricPoint {
|
||||
timestamp: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface MetricSeries {
|
||||
name: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
points: MetricPoint[];
|
||||
}
|
||||
|
||||
export interface MetricsReport {
|
||||
series: MetricSeries[];
|
||||
}
|
||||
|
||||
export interface AvailableChecker {
|
||||
checker_name: string;
|
||||
enabled: boolean;
|
||||
|
|
|
|||
|
|
@ -34,3 +34,6 @@ export async function refreshCheckers() {
|
|||
export const currentCheckResult: Writable<CheckResult | null> = writable(null);
|
||||
export const currentCheckInfo: Writable<CheckerInfo | null> = writable(null);
|
||||
export const showHTMLReport: Writable<boolean> = writable(true);
|
||||
|
||||
export type ReportViewMode = "metrics" | "html" | "json";
|
||||
export const reportViewMode: Writable<ReportViewMode> = writable("html");
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
deleteServiceCheckResult,
|
||||
deleteAllServiceCheckResults,
|
||||
getServiceCheckExecution,
|
||||
getServiceCheckMetrics,
|
||||
} from "$lib/api/checkers";
|
||||
import type { Domain } from "$lib/model/domain";
|
||||
import { CheckScopeType } from "$lib/model/checker";
|
||||
|
|
@ -66,6 +67,8 @@
|
|||
deleteServiceCheckResult(data.domain.id, data.zoneId, data.subdomain, data.serviceid, checkerName, id)}
|
||||
deleteAllResults={() =>
|
||||
deleteAllServiceCheckResults(data.domain.id, data.zoneId, data.subdomain, data.serviceid, checkerName)}
|
||||
loadMetrics={() =>
|
||||
getServiceCheckMetrics(data.domain.id, data.zoneId, data.subdomain, data.serviceid, checkerName, 50)}
|
||||
zoneId={data.zoneId}
|
||||
subdomain={data.subdomain}
|
||||
serviceid={data.serviceid}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
deleteCheckResult,
|
||||
deleteAllCheckResults,
|
||||
getCheckExecution,
|
||||
getCheckMetrics,
|
||||
} from "$lib/api/checkers";
|
||||
import type { Domain } from "$lib/model/domain";
|
||||
import { CheckScopeType } from "$lib/model/checker";
|
||||
|
|
@ -55,4 +56,5 @@
|
|||
getExecution={(id) => getCheckExecution(data.domain.id, checkName, id)}
|
||||
deleteResult={(id) => deleteCheckResult(data.domain.id, checkName, id)}
|
||||
deleteAllResults={() => deleteAllCheckResults(data.domain.id, checkName)}
|
||||
loadMetrics={() => getCheckMetrics(data.domain.id, checkName, 50)}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue