mirror of
https://github.com/mentos1386/zdravko.git
synced 2024-11-21 15:26:29 +00:00
feat(index): more information about checks and worker groups
This commit is contained in:
parent
2a0447bc7b
commit
3e8832709a
8 changed files with 232 additions and 117 deletions
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/mentos1386/zdravko/database/models"
|
||||
"github.com/mentos1386/zdravko/internal/server/services"
|
||||
"github.com/mentos1386/zdravko/internal/temporal"
|
||||
"github.com/mentos1386/zdravko/pkg/script"
|
||||
|
@ -25,6 +26,10 @@ func (a *Activities) TargetsFilter(ctx context.Context, param temporal.ActivityT
|
|||
}
|
||||
|
||||
for _, target := range allTargets {
|
||||
if target.State == models.TargetStatePaused {
|
||||
continue
|
||||
}
|
||||
|
||||
vm := goja.New()
|
||||
vm.SetFieldNameMapper(goja.UncapFieldNameMapper())
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ package handlers
|
|||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
@ -24,19 +25,33 @@ type Target struct {
|
|||
Visibility models.TargetVisibility
|
||||
Group string
|
||||
Status models.TargetStatus
|
||||
History *History
|
||||
}
|
||||
|
||||
type HistoryItem struct {
|
||||
Status models.TargetStatus
|
||||
Date time.Time
|
||||
History []*HistoryItem
|
||||
Uptime float64
|
||||
}
|
||||
|
||||
type History struct {
|
||||
List []HistoryItem
|
||||
List []*HistoryItem
|
||||
Uptime float64
|
||||
}
|
||||
|
||||
type HistoryItem struct {
|
||||
Status models.TargetStatus
|
||||
StatusCounts map[models.TargetStatus]int
|
||||
Counts int
|
||||
Date time.Time
|
||||
Checks []*HistoryItemCheck
|
||||
SuccessRate float64
|
||||
}
|
||||
|
||||
type HistoryItemCheck struct {
|
||||
Name string
|
||||
WorkerGroupName string
|
||||
Status models.TargetStatus
|
||||
StatusCounts map[models.TargetStatus]int
|
||||
Counts int
|
||||
SuccessRate float64
|
||||
}
|
||||
|
||||
type TargetsAndStatus struct {
|
||||
Status models.TargetStatus
|
||||
Targets []*Target
|
||||
|
@ -47,20 +62,31 @@ func getDateString(date time.Time) string {
|
|||
}
|
||||
|
||||
func getHistory(history []*services.TargetHistory, period time.Duration, buckets int) *History {
|
||||
historyMap := map[string]models.TargetStatus{}
|
||||
historyMap := map[string]*HistoryItem{}
|
||||
numOfSuccess := 0.0
|
||||
numTotal := 0.0
|
||||
|
||||
mapKeys := make([]string, buckets)
|
||||
|
||||
for i := 0; i < buckets; i++ {
|
||||
dateString := getDateString(time.Now().Add(period * time.Duration(-i)).Truncate(period))
|
||||
historyMap[dateString] = models.TargetStatusUnknown
|
||||
date := time.Now().Add(period * time.Duration(-i)).Truncate(period)
|
||||
dateString := getDateString(date)
|
||||
mapKeys[i] = dateString
|
||||
|
||||
historyMap[dateString] = &HistoryItem{
|
||||
Status: models.TargetStatusUnknown,
|
||||
StatusCounts: map[models.TargetStatus]int{},
|
||||
Date: date,
|
||||
Checks: []*HistoryItemCheck{},
|
||||
SuccessRate: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
for _, _history := range history {
|
||||
dateString := getDateString(_history.CreatedAt.Time.Truncate(period))
|
||||
|
||||
// Skip if not part of the "buckets"
|
||||
if _, ok := historyMap[dateString]; !ok {
|
||||
entry, ok := historyMap[dateString]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
|
@ -69,24 +95,58 @@ func getHistory(history []*services.TargetHistory, period time.Duration, buckets
|
|||
numOfSuccess++
|
||||
}
|
||||
|
||||
// skip if it is already set to failure
|
||||
if historyMap[dateString] == models.TargetStatusFailure {
|
||||
continue
|
||||
if entry.Status == models.TargetStatusUnknown {
|
||||
entry.Status = _history.Status
|
||||
}
|
||||
|
||||
// FIXME: This is wrong! As we can have multiple targets in dateString.
|
||||
// We should look at only the newest one.
|
||||
historyMap[dateString] = _history.Status
|
||||
}
|
||||
|
||||
historyItems := make([]HistoryItem, buckets)
|
||||
for i := 0; i < buckets; i++ {
|
||||
date := time.Now().Add(period * time.Duration(-buckets+i+1)).Truncate(period)
|
||||
datestring := getDateString(date)
|
||||
historyItems[i] = HistoryItem{
|
||||
Status: historyMap[datestring],
|
||||
Date: date,
|
||||
// If not yet failure, and failing check. Mark as failing.
|
||||
if _history.Status == models.TargetStatusFailure && entry.Status != models.TargetStatusFailure {
|
||||
entry.Status = models.TargetStatusFailure
|
||||
}
|
||||
|
||||
entry.StatusCounts[_history.Status]++
|
||||
entry.Counts++
|
||||
entry.SuccessRate = 100.0 * float64(entry.StatusCounts[models.TargetStatusSuccess]) / float64(entry.Counts)
|
||||
|
||||
foundCheck := false
|
||||
for _, check := range entry.Checks {
|
||||
if check.Name == _history.CheckName && check.WorkerGroupName == _history.WorkerGroupName {
|
||||
foundCheck = true
|
||||
|
||||
check.StatusCounts[_history.Status]++
|
||||
check.Counts++
|
||||
check.SuccessRate = 100.0 * float64(check.StatusCounts[models.TargetStatusSuccess]) / float64(check.Counts)
|
||||
|
||||
if check.Status != models.TargetStatusFailure && _history.Status == models.TargetStatusFailure {
|
||||
check.Status = models.TargetStatusFailure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !foundCheck {
|
||||
successRate := 0.0
|
||||
if _history.Status == models.TargetStatusSuccess {
|
||||
successRate = 100.0
|
||||
}
|
||||
entry.Checks = append(entry.Checks, &HistoryItemCheck{
|
||||
Name: _history.CheckName,
|
||||
WorkerGroupName: _history.WorkerGroupName,
|
||||
Status: _history.Status,
|
||||
StatusCounts: map[models.TargetStatus]int{
|
||||
_history.Status: 1,
|
||||
},
|
||||
Counts: 1,
|
||||
SuccessRate: successRate,
|
||||
})
|
||||
|
||||
sort.Slice(entry.Checks, func(i, j int) bool {
|
||||
byName := entry.Checks[i].Name < entry.Checks[j].Name
|
||||
byWorkerGroupName := entry.Checks[i].WorkerGroupName < entry.Checks[j].WorkerGroupName
|
||||
return byName || (entry.Checks[i].Name == entry.Checks[j].Name && byWorkerGroupName)
|
||||
})
|
||||
}
|
||||
|
||||
historyMap[dateString] = entry
|
||||
}
|
||||
|
||||
uptime := 0.0
|
||||
|
@ -94,6 +154,12 @@ func getHistory(history []*services.TargetHistory, period time.Duration, buckets
|
|||
uptime = 100.0 * numOfSuccess / numTotal
|
||||
}
|
||||
|
||||
historyItems := make([]*HistoryItem, 0, len(historyMap))
|
||||
for i := buckets - 1; i >= 0; i-- {
|
||||
key := mapKeys[i]
|
||||
historyItems = append(historyItems, historyMap[key])
|
||||
}
|
||||
|
||||
return &History{
|
||||
List: historyItems,
|
||||
Uptime: uptime,
|
||||
|
@ -107,9 +173,27 @@ func (h *BaseHandler) Index(c echo.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
timeRange := c.QueryParam("time-range")
|
||||
if timeRange != "48hours" && timeRange != "90days" && timeRange != "90minutes" {
|
||||
timeRange = "90days"
|
||||
timeRangeQuery := c.QueryParam("time-range")
|
||||
if timeRangeQuery != "48hours" && timeRangeQuery != "90days" && timeRangeQuery != "90minutes" {
|
||||
timeRangeQuery = "90days"
|
||||
}
|
||||
|
||||
var timeBuckets int
|
||||
var timeInterval time.Duration
|
||||
var timeRange services.TargetHistoryDateRange
|
||||
switch timeRangeQuery {
|
||||
case "90days":
|
||||
timeRange = services.TargetHistoryDateRange90Days
|
||||
timeInterval = time.Hour * 24
|
||||
timeBuckets = 90
|
||||
case "48hours":
|
||||
timeRange = services.TargetHistoryDateRange48Hours
|
||||
timeInterval = time.Hour
|
||||
timeBuckets = 48
|
||||
case "90minutes":
|
||||
timeRange = services.TargetHistoryDateRange90Minutes
|
||||
timeInterval = time.Minute
|
||||
timeBuckets = 90
|
||||
}
|
||||
|
||||
overallStatus := models.TargetStatusUnknown
|
||||
|
@ -117,20 +201,12 @@ func (h *BaseHandler) Index(c echo.Context) error {
|
|||
|
||||
targetsWithHistory := make([]*Target, len(targets))
|
||||
for i, target := range targets {
|
||||
history, err := services.GetTargetHistoryForTarget(ctx, h.db, target.Id)
|
||||
history, err := services.GetTargetHistoryForTarget(ctx, h.db, target.Id, timeRange)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var historyResult *History
|
||||
switch timeRange {
|
||||
case "48hours":
|
||||
historyResult = getHistory(history, time.Hour, 48)
|
||||
case "90days":
|
||||
historyResult = getHistory(history, time.Hour*24, 90)
|
||||
case "90minutes":
|
||||
historyResult = getHistory(history, time.Minute, 90)
|
||||
}
|
||||
historyResult := getHistory(history, timeInterval, timeBuckets)
|
||||
|
||||
if statusByGroup[target.Group] == "" {
|
||||
statusByGroup[target.Group] = models.TargetStatusUnknown
|
||||
|
@ -155,7 +231,8 @@ func (h *BaseHandler) Index(c echo.Context) error {
|
|||
Visibility: target.Visibility,
|
||||
Group: target.Group,
|
||||
Status: status.Status,
|
||||
History: historyResult,
|
||||
History: historyResult.List,
|
||||
Uptime: historyResult.Uptime,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -175,7 +252,7 @@ func (h *BaseHandler) Index(c echo.Context) error {
|
|||
Navbar: Pages,
|
||||
},
|
||||
Targets: targetsByGroup,
|
||||
TimeRange: timeRange,
|
||||
TimeRange: timeRangeQuery,
|
||||
Status: overallStatus,
|
||||
})
|
||||
}
|
||||
|
|
|
@ -83,7 +83,7 @@ func (h *BaseHandler) SettingsTargetsDescribeGET(c echo.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
history, err := services.GetTargetHistoryForTarget(context.Background(), h.db, slug)
|
||||
history, err := services.GetTargetHistoryForTarget(context.Background(), h.db, slug, services.TargetHistoryDateRange90Minutes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package services
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/mentos1386/zdravko/database/models"
|
||||
|
@ -14,28 +15,30 @@ type TargetHistory struct {
|
|||
CheckName string `db:"check_name"`
|
||||
}
|
||||
|
||||
func GetLastNTargetHistory(ctx context.Context, db *sqlx.DB, n int) ([]*TargetHistory, error) {
|
||||
var targetHistory []*TargetHistory
|
||||
err := db.SelectContext(ctx, &targetHistory, `
|
||||
SELECT
|
||||
th.*,
|
||||
t.name AS target_name,
|
||||
wg.name AS worker_group_name,
|
||||
c.name AS check_name
|
||||
FROM target_histories th
|
||||
LEFT JOIN targets t ON th.target_id = t.id
|
||||
LEFT JOIN worker_groups wg ON th.worker_group_id = wg.id
|
||||
LEFT JOIN checks c ON th.check_id = c.id
|
||||
WHERE th.target_id = $1
|
||||
ORDER BY th.created_at DESC
|
||||
LIMIT $1
|
||||
`, n)
|
||||
return targetHistory, err
|
||||
}
|
||||
type TargetHistoryDateRange string
|
||||
|
||||
const (
|
||||
TargetHistoryDateRange90Days TargetHistoryDateRange = "90_DAYS"
|
||||
TargetHistoryDateRange48Hours TargetHistoryDateRange = "48_HOURS"
|
||||
TargetHistoryDateRange90Minutes TargetHistoryDateRange = "90_MINUTES"
|
||||
)
|
||||
|
||||
func GetTargetHistoryForTarget(ctx context.Context, db *sqlx.DB, targetId string, dateRange TargetHistoryDateRange) ([]*TargetHistory, error) {
|
||||
dateRangeFilter := ""
|
||||
switch dateRange {
|
||||
case TargetHistoryDateRange90Days:
|
||||
dateRangeFilter = "AND strftime('%Y-%m-%dT%H:%M:%fZ', th.created_at) >= datetime('now', 'localtime', '-90 days')"
|
||||
case TargetHistoryDateRange48Hours:
|
||||
dateRangeFilter = "AND strftime('%Y-%m-%dT%H:%M:%fZ', th.created_at) >= datetime('now', 'localtime', '-48 hours')"
|
||||
case TargetHistoryDateRange90Minutes:
|
||||
dateRangeFilter = "AND strftime('%Y-%m-%dT%H:%M:%fZ', th.created_at) >= datetime('now', 'localtime', '-90 minutes')"
|
||||
}
|
||||
|
||||
func GetTargetHistoryForTarget(ctx context.Context, db *sqlx.DB, targetId string) ([]*TargetHistory, error) {
|
||||
var targetHistory []*TargetHistory
|
||||
err := db.SelectContext(ctx, &targetHistory, `
|
||||
err := db.SelectContext(
|
||||
ctx,
|
||||
&targetHistory,
|
||||
fmt.Sprintf(`
|
||||
SELECT
|
||||
th.*,
|
||||
t.name AS target_name,
|
||||
|
@ -45,9 +48,13 @@ func GetTargetHistoryForTarget(ctx context.Context, db *sqlx.DB, targetId string
|
|||
LEFT JOIN targets t ON th.target_id = t.id
|
||||
LEFT JOIN worker_groups wg ON th.worker_group_id = wg.id
|
||||
LEFT JOIN checks c ON th.check_id = c.id
|
||||
WHERE th.target_id = $1
|
||||
WHERE
|
||||
th.target_id = $1
|
||||
%s
|
||||
ORDER BY th.created_at DESC
|
||||
`, targetId)
|
||||
`, dateRangeFilter),
|
||||
targetId,
|
||||
)
|
||||
return targetHistory, err
|
||||
}
|
||||
|
||||
|
|
|
@ -730,10 +730,6 @@ video {
|
|||
height: 5rem;
|
||||
}
|
||||
|
||||
.h-3 {
|
||||
height: 0.75rem;
|
||||
}
|
||||
|
||||
.h-4 {
|
||||
height: 1rem;
|
||||
}
|
||||
|
@ -768,10 +764,6 @@ video {
|
|||
width: 5rem;
|
||||
}
|
||||
|
||||
.w-3 {
|
||||
width: 0.75rem;
|
||||
}
|
||||
|
||||
.w-4 {
|
||||
width: 1rem;
|
||||
}
|
||||
|
@ -845,6 +837,10 @@ video {
|
|||
justify-content: center;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.gap-1 {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
|
|
@ -101,19 +101,17 @@
|
|||
<summary
|
||||
class="flex flex-row gap-2 p-3 py-2 -mx-3 cursor-pointer hover:bg-blue-50 rounded-lg"
|
||||
>
|
||||
{{ if eq $targetsAndStatus.Status "SUCCESS" }}
|
||||
<span
|
||||
class="flex w-3 h-3 bg-green-400 rounded-full self-center"
|
||||
></span>
|
||||
{{ else if eq $targetsAndStatus.Status "FAILURE" }}
|
||||
<span
|
||||
class="flex w-3 h-3 bg-red-400 rounded-full self-center"
|
||||
></span>
|
||||
{{ else }}
|
||||
<span
|
||||
class="flex w-3 h-3 bg-gray-200 rounded-full self-center"
|
||||
></span>
|
||||
{{ end }}
|
||||
<span
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
{{ if eq $targetsAndStatus.Status "SUCCESS" }}
|
||||
bg-green-100 text-green-800
|
||||
{{ else if eq $targetsAndStatus.Status "FAILURE" }}
|
||||
bg-red-100 text-red-800
|
||||
{{ else }}
|
||||
bg-gray-100 text-gray-800
|
||||
{{ end }}"
|
||||
>{{ .Status }}</span
|
||||
>
|
||||
<h2 class="flex-1 font-semibold capitalize">
|
||||
{{ $group }}
|
||||
</h2>
|
||||
|
@ -128,13 +126,17 @@
|
|||
class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2 pb-2 border-b last-of-type:pb-0 last-of-type:border-0 border-gray-100"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
{{ if eq .Status "SUCCESS" }}
|
||||
<span class="flex w-3 h-3 bg-green-400 rounded-full"></span>
|
||||
{{ else if eq .Status "FAILURE" }}
|
||||
<span class="flex w-3 h-3 bg-red-400 rounded-full"></span>
|
||||
{{ else }}
|
||||
<span class="flex w-3 h-3 bg-gray-200 rounded-full"></span>
|
||||
{{ end }}
|
||||
<span
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
{{ if eq .Status "SUCCESS" }}
|
||||
bg-green-100 text-green-800
|
||||
{{ else if eq .Status "FAILURE" }}
|
||||
bg-red-100 text-red-800
|
||||
{{ else }}
|
||||
bg-gray-100 text-gray-800
|
||||
{{ end }}"
|
||||
>{{ .Status }}</span
|
||||
>
|
||||
<h4>
|
||||
{{ .Name }}
|
||||
{{ if eq .Visibility "PUBLIC" }}
|
||||
|
@ -159,12 +161,12 @@
|
|||
</h4>
|
||||
</div>
|
||||
<div class="justify-self-end text-sm">
|
||||
{{ printf "%.2f" .History.Uptime }}% uptime
|
||||
{{ printf "%.2f" .Uptime }}% uptime
|
||||
</div>
|
||||
<div
|
||||
class="grid gap-px col-span-2 grid-flow-col h-8 rounded overflow-hidden"
|
||||
>
|
||||
{{ range .History.List }}
|
||||
{{ range .History }}
|
||||
<div
|
||||
class="has-tooltip [&_.tooltip]:hover:flex [&_.tooltip]:hover:visible flex"
|
||||
>
|
||||
|
@ -182,27 +184,51 @@
|
|||
></div>
|
||||
{{ end }}
|
||||
<div
|
||||
class="tooltip gap-2 bg-white border border-gray-200 rounded p-2 shadow-lg hidden z-50 absolute mt-10 -ml-4 flex-row text-xs"
|
||||
class="tooltip flex flex-col gap-2 bg-white border border-gray-200 rounded p-2 shadow-lg hidden z-50 absolute mt-10 -ml-4 flex-row text-xs"
|
||||
>
|
||||
{{ if eq .Status "SUCCESS" }}
|
||||
<div
|
||||
class="flex flex-row gap-2 justify-between pb-2 border-b border-gray-200"
|
||||
>
|
||||
{{ if eq $.TimeRange "90days" }}
|
||||
{{ .Date.Format "Jan 02" }}
|
||||
{{ else if eq $.TimeRange "48hours" }}
|
||||
{{ .Date.Format "Jan 02, 15:00 MST" }}
|
||||
{{ else if eq $.TimeRange "90minutes" }}
|
||||
{{ .Date.Format "Jan 02, 15:04 MST" }}
|
||||
{{ end }}
|
||||
<span
|
||||
class="flex w-3 h-3 bg-green-400 rounded-full self-center"
|
||||
></span>
|
||||
{{ else if eq .Status "FAILURE" }}
|
||||
<span
|
||||
class="flex w-3 h-3 bg-red-400 rounded-full self-center"
|
||||
></span>
|
||||
{{ else }}
|
||||
<span
|
||||
class="flex w-3 h-3 bg-gray-200 rounded-full self-center"
|
||||
></span>
|
||||
{{ end }}
|
||||
{{ if eq $.TimeRange "90days" }}
|
||||
{{ .Date.Format "Jan 02" }}
|
||||
{{ else if eq $.TimeRange "48hours" }}
|
||||
{{ .Date.Format "Jan 02, 15:00 MST" }}
|
||||
{{ else if eq $.TimeRange "90minutes" }}
|
||||
{{ .Date.Format "Jan 02, 15:04 MST" }}
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
{{ if eq .Status "SUCCESS" }}
|
||||
bg-green-100 text-green-800
|
||||
{{ else if eq .Status "FAILURE" }}
|
||||
bg-red-100 text-red-800
|
||||
{{ else }}
|
||||
bg-gray-100 text-gray-800
|
||||
{{ end }}"
|
||||
>{{ .Status }}
|
||||
({{ printf "%.2f" .SuccessRate }}%)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{ range .Checks }}
|
||||
<div class="flex flex-row gap-2 justify-between">
|
||||
<p>
|
||||
{{ .Name }} on
|
||||
{{ .WorkerGroupName }}
|
||||
</p>
|
||||
<span
|
||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
{{ if eq .Status "SUCCESS" }}
|
||||
bg-green-100 text-green-800
|
||||
{{ else if eq .Status "FAILURE" }}
|
||||
bg-red-100 text-red-800
|
||||
{{ else }}
|
||||
bg-gray-100 text-gray-800
|
||||
{{ end }}"
|
||||
>{{ .Status }}
|
||||
({{ printf "%.2f" .SuccessRate }}%)
|
||||
</span>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -117,8 +117,7 @@
|
|||
<th>Status</th>
|
||||
<th>Worker Group</th>
|
||||
<th>Check</th>
|
||||
<th>Created At</th>
|
||||
<th>Duration</th>
|
||||
<th>Checked At</th>
|
||||
<th>Note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
@ -153,7 +152,6 @@
|
|||
<td>
|
||||
{{ .CreatedAt.Time.Format "2006-01-02 15:04:05" }}
|
||||
</td>
|
||||
<td>{ .Duration }</td>
|
||||
<td class="whitespace-normal">
|
||||
{{ .Note }}
|
||||
</td>
|
||||
|
|
|
@ -26,6 +26,12 @@ func load(files ...string) *template.Template {
|
|||
|
||||
t := template.New("default").Funcs(
|
||||
template.FuncMap{
|
||||
"MathDivide": func(a, b int) float64 {
|
||||
if b == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(a) / float64(b)
|
||||
},
|
||||
"DurationRoundSecond": func(d time.Duration) time.Duration {
|
||||
return d.Round(time.Second)
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue