check / check (push) Successful in 3m56s
The webhook page opens with a statistics pane: entrypoints and targets, deliveries in progress, the last arrival, the retention period; events, deliveries and failures, lifetime and within retention; and events, failures and failure percentage over 10 minutes and 24 hours. Running totals, one row per target plus one for events, sit in each webhook's event database and are written in the same transaction as the rows they count; retention prunes in paused, stoppable batches and subtracts what it removes. Window figures are index-range counts on a new finished_at column. An existing event database must be recreated. Model: opus-5-5
570 lines
17 KiB
Go
570 lines
17 KiB
Go
package handlers_test
|
|
|
|
import (
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/fx/fxtest"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
"sneak.berlin/go/webhooker/internal/config"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/handlers"
|
|
"sneak.berlin/go/webhooker/internal/logger"
|
|
"sneak.berlin/go/webhooker/internal/session"
|
|
)
|
|
|
|
// statsEntrypoint adds an entrypoint to a webhook and returns its path.
|
|
func statsEntrypoint(
|
|
t *testing.T, db *database.Database, webhookID string, active bool,
|
|
) string {
|
|
t.Helper()
|
|
|
|
ep := &database.Entrypoint{
|
|
WebhookID: webhookID,
|
|
Path: uuid.New().String(),
|
|
}
|
|
|
|
require.NoError(t, db.DB().Omit(clause.Associations).Create(ep).Error)
|
|
require.NoError(t, db.DB().Model(ep).Update("active", active).Error)
|
|
|
|
return ep.Path
|
|
}
|
|
|
|
// statsDelivery returns an event's delivery to a target.
|
|
func statsDelivery(
|
|
t *testing.T, webhookDB *gorm.DB, eventID, targetID string,
|
|
) database.Delivery {
|
|
t.Helper()
|
|
|
|
var d database.Delivery
|
|
|
|
require.NoError(t, webhookDB.Where(
|
|
"event_id = ? AND target_id = ?", eventID, targetID,
|
|
).First(&d).Error)
|
|
|
|
return d
|
|
}
|
|
|
|
// statsFinish settles a delivery as the delivery engine does: its
|
|
// final status and the time it finished, and one more on its target's
|
|
// delivered or failed total, in one transaction.
|
|
func statsFinish(
|
|
t *testing.T,
|
|
webhookDB *gorm.DB,
|
|
d database.Delivery,
|
|
status database.DeliveryStatus,
|
|
at time.Time,
|
|
) {
|
|
t.Helper()
|
|
|
|
add := database.TargetTotals{TargetID: d.TargetID, Delivered: 1}
|
|
if status == database.DeliveryStatusFailed {
|
|
add = database.TargetTotals{TargetID: d.TargetID, Failed: 1}
|
|
}
|
|
|
|
require.NoError(t, webhookDB.Transaction(func(tx *gorm.DB) error {
|
|
err := tx.Model(&database.Delivery{}).
|
|
Where("id = ?", d.ID).
|
|
Updates(map[string]any{"status": status, "finished_at": at}).
|
|
Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return database.AddTargetTotals(tx, add)
|
|
}))
|
|
}
|
|
|
|
// statsAge moves an event's arrival back to the given time.
|
|
func statsAge(
|
|
t *testing.T, webhookDB *gorm.DB, eventID string, at time.Time,
|
|
) {
|
|
t.Helper()
|
|
|
|
require.NoError(t, webhookDB.Model(&database.Event{}).
|
|
Where("id = ?", eventID).
|
|
Update("created_at", at).Error)
|
|
}
|
|
|
|
// statsTargetTotals reads a webhook database's target totals, keyed by
|
|
// target.
|
|
func statsTargetTotals(
|
|
t *testing.T, webhookDB *gorm.DB,
|
|
) map[string]database.TargetTotals {
|
|
t.Helper()
|
|
|
|
var rows []database.TargetTotals
|
|
|
|
require.NoError(t, webhookDB.Find(&rows).Error)
|
|
|
|
byTarget := make(map[string]database.TargetTotals, len(rows))
|
|
for _, row := range rows {
|
|
byTarget[row.TargetID] = row
|
|
}
|
|
|
|
return byTarget
|
|
}
|
|
|
|
// statsHistory is the webhook seedStatsHistory builds: its event
|
|
// database, its newest event, and its two active targets.
|
|
type statsHistory struct {
|
|
webhook *database.Webhook
|
|
webhookDB *gorm.DB
|
|
newest database.Event
|
|
first, second string
|
|
}
|
|
|
|
// seedStatsHistory builds the webhook the statistics test checks: 14
|
|
// days of retention, twelve entrypoints (one inactive) and six targets
|
|
// (four inactive). Ten events arrive through the receiver, and so each
|
|
// has a delivery to the two active targets. The oldest event is past
|
|
// retention, the next 30 hours old, the next six hours old, the other
|
|
// seven just in. Six deliveries are settled as the delivery engine
|
|
// would, two of them inside a recent window though their event arrived
|
|
// before it. The newest event's delivery to the second target is
|
|
// retrying, the rest are left pending, and a replay adds a pending
|
|
// delivery to the oldest event. Once retention has removed the oldest
|
|
// event, every figure in the pane differs from every other.
|
|
func seedStatsHistory(
|
|
t *testing.T,
|
|
h *handlers.Handlers,
|
|
sess *session.Session,
|
|
db *database.Database,
|
|
dbMgr *database.WebhookDBManager,
|
|
) statsHistory {
|
|
t.Helper()
|
|
|
|
wh := &database.Webhook{UserID: deleteTestUserID, Name: "stats", RetentionDays: 14}
|
|
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
|
|
|
path := statsEntrypoint(t, db, wh.ID, true)
|
|
for range 10 {
|
|
statsEntrypoint(t, db, wh.ID, true)
|
|
}
|
|
|
|
statsEntrypoint(t, db, wh.ID, false)
|
|
|
|
first := seedConfiguredTarget(
|
|
t, db, wh.ID, database.TargetTypeHTTP,
|
|
`{"url":"`+replayTargetURL+`"}`,
|
|
)
|
|
second := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
|
|
|
for range 4 {
|
|
inactive := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
|
require.NoError(t, db.DB().Model(inactive).
|
|
Update("active", false).Error)
|
|
}
|
|
|
|
router := receiverRouter(h)
|
|
|
|
for range 10 {
|
|
require.Equal(t, http.StatusOK, postReceiver(t, router, path))
|
|
}
|
|
|
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
require.NoError(t, err)
|
|
|
|
events := listEvents(t, webhookDB)
|
|
require.Len(t, events, 10)
|
|
|
|
oldest, yesterday, middle, newest := events[0], events[1], events[2], events[9]
|
|
now := time.Now()
|
|
|
|
statsAge(t, webhookDB, oldest.ID, now.Add(-15*24*time.Hour))
|
|
statsAge(t, webhookDB, yesterday.ID, now.Add(-30*time.Hour))
|
|
statsAge(t, webhookDB, middle.ID, now.Add(-6*time.Hour))
|
|
|
|
oldestFailure := statsDelivery(t, webhookDB, oldest.ID, first.ID)
|
|
statsFinish(t, webhookDB, oldestFailure,
|
|
database.DeliveryStatusFailed, now.Add(-14*24*time.Hour))
|
|
statsFinish(t, webhookDB,
|
|
statsDelivery(t, webhookDB, yesterday.ID, first.ID),
|
|
database.DeliveryStatusFailed, now.Add(-29*time.Hour))
|
|
statsFinish(t, webhookDB,
|
|
statsDelivery(t, webhookDB, yesterday.ID, second.ID),
|
|
database.DeliveryStatusFailed, now.Add(-23*time.Hour))
|
|
statsFinish(t, webhookDB,
|
|
statsDelivery(t, webhookDB, middle.ID, second.ID),
|
|
database.DeliveryStatusFailed, now.Add(-5*time.Hour))
|
|
statsFinish(t, webhookDB,
|
|
statsDelivery(t, webhookDB, middle.ID, first.ID),
|
|
database.DeliveryStatusFailed, now.Add(-time.Minute))
|
|
statsFinish(t, webhookDB,
|
|
statsDelivery(t, webhookDB, newest.ID, first.ID),
|
|
database.DeliveryStatusDelivered, now.Add(-2*time.Minute))
|
|
|
|
retrying := statsDelivery(t, webhookDB, newest.ID, second.ID)
|
|
require.NoError(t, webhookDB.Model(&retrying).
|
|
Update("status", database.DeliveryStatusRetrying).Error)
|
|
|
|
require.Equal(t, http.StatusSeeOther,
|
|
postReplay(t, h, sess, wh.ID, oldestFailure.ID).Code)
|
|
|
|
return statsHistory{
|
|
webhook: wh, webhookDB: webhookDB, newest: newest,
|
|
first: first.ID, second: second.ID,
|
|
}
|
|
}
|
|
|
|
// statsPrune runs the real retention reaper until it has removed one
|
|
// event from the webhook's database, then stops it.
|
|
func statsPrune(
|
|
t *testing.T,
|
|
db *database.Database,
|
|
dbMgr *database.WebhookDBManager,
|
|
log *logger.Logger,
|
|
webhookDB *gorm.DB,
|
|
) {
|
|
t.Helper()
|
|
|
|
lc := fxtest.NewLifecycle(t)
|
|
database.NewRetentionReaper(lc, database.RetentionReaperParams{
|
|
Config: &config.Config{
|
|
RetentionSweepInterval: 10 * time.Millisecond,
|
|
},
|
|
Database: db,
|
|
DBManager: dbMgr,
|
|
Logger: log,
|
|
})
|
|
|
|
lc.RequireStart()
|
|
|
|
require.Eventually(t, func() bool {
|
|
var totals database.EventTotals
|
|
|
|
err := webhookDB.Take(&totals).Error
|
|
|
|
return err == nil && totals.EventsRemoved == 1
|
|
}, 10*time.Second, 10*time.Millisecond)
|
|
|
|
lc.RequireStop()
|
|
}
|
|
|
|
// statsPane returns the text of the statistics pane in a rendered
|
|
// webhook page, everything from its heading to the next heading on the
|
|
// page, with the markup taken out and each run of space made one
|
|
// space. A table then reads header by header and row by row, each
|
|
// row's label followed by its figures in column order.
|
|
func statsPane(t *testing.T, page string) string {
|
|
t.Helper()
|
|
|
|
_, pane, found := strings.Cut(page, ">Statistics</h2>")
|
|
require.True(t, found, "the page has no statistics pane")
|
|
|
|
pane, _, _ = strings.Cut(pane, "<h2")
|
|
pane = regexp.MustCompile(`<[^>]*>`).ReplaceAllString(pane, " ")
|
|
|
|
return strings.Join(strings.Fields(pane), " ")
|
|
}
|
|
|
|
// assertStatsTargets checks, for the history seedStatsHistory builds,
|
|
// each target's totals and its deliveries finished in the last 24
|
|
// hours. The first target has ten deliveries and the replay, the
|
|
// second ten; the inactive targets have none and so no row.
|
|
func assertStatsTargets(t *testing.T, hist statsHistory) {
|
|
t.Helper()
|
|
|
|
first, second := hist.first, hist.second
|
|
|
|
assert.Equal(t, map[string]database.TargetTotals{
|
|
first: {TargetID: first, Deliveries: 11, Delivered: 1, Failed: 3},
|
|
second: {TargetID: second, Deliveries: 10, Failed: 2},
|
|
}, statsTargetTotals(t, hist.webhookDB))
|
|
|
|
lastDay, err := handlers.FinishedByTargetForTest(
|
|
hist.webhookDB, time.Now().Add(-24*time.Hour),
|
|
)
|
|
require.NoError(t, err)
|
|
assert.ElementsMatch(t, []handlers.TargetFinished{
|
|
{TargetID: first, Delivered: 1, Failed: 1},
|
|
{TargetID: second, Failed: 2},
|
|
}, lastDay)
|
|
}
|
|
|
|
// assertStatsPaneAfterPrune checks the rendered statistics pane for the
|
|
// history seedStatsHistory builds, once retention has removed the
|
|
// oldest event: each figure after its label, in its column.
|
|
func assertStatsPaneAfterPrune(
|
|
t *testing.T,
|
|
h *handlers.Handlers,
|
|
sess *session.Session,
|
|
hist statsHistory,
|
|
) {
|
|
t.Helper()
|
|
|
|
pane := statsPane(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID))
|
|
lastEvent := hist.newest.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC")
|
|
|
|
assert.Contains(t, pane, "Entrypoints 12 (11 active) "+
|
|
"Targets 6 (2 active) "+
|
|
"Deliveries in progress 13 "+
|
|
"Last event "+lastEvent+" "+
|
|
"Retention 14 days")
|
|
assert.Contains(t, pane, "Lifetime Within retention "+
|
|
"Events 10 9 "+
|
|
"Deliveries 21 18 "+
|
|
"Failures 5 4")
|
|
assert.Contains(t, pane, "Last 10 minutes Last 24 hours "+
|
|
"Events 7 8 "+
|
|
"Failures 1 3 "+
|
|
"Failure percentage 50.0% 75.0%")
|
|
}
|
|
|
|
// TestWebhookStats_EveryFigureAcrossRetentionPrune checks every figure
|
|
// the statistics pane shows for the history seedStatsHistory builds,
|
|
// and each target's totals and recent figures, before and after the
|
|
// real retention reaper removes the oldest event.
|
|
func TestWebhookStats_EveryFigureAcrossRetentionPrune(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
dbMgr *database.WebhookDBManager
|
|
log *logger.Logger
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
hist := seedStatsHistory(t, h, sess, db, dbMgr)
|
|
first, second := hist.first, hist.second
|
|
|
|
stats := h.WebhookStatsForTest(hist.webhook.ID)
|
|
require.NotNil(t, stats)
|
|
|
|
assert.Equal(t, 12, stats.Entrypoints)
|
|
assert.Equal(t, 11, stats.ActiveEntrypoints)
|
|
assert.Equal(t, 6, stats.Targets)
|
|
assert.Equal(t, 2, stats.ActiveTargets)
|
|
assert.Equal(t, handlers.Counts{Events: 10, Deliveries: 21, Failures: 5},
|
|
stats.Lifetime)
|
|
assert.Equal(t, stats.Lifetime, stats.WithinRetention)
|
|
assert.Equal(t, int64(15), stats.InProgress)
|
|
require.NotNil(t, stats.LastEventAt)
|
|
assert.True(t, hist.newest.CreatedAt.Equal(*stats.LastEventAt))
|
|
assert.Equal(t, handlers.RecentWindow{
|
|
Events: 7, Delivered: 1, Failed: 1,
|
|
}, stats.Last10Minutes)
|
|
assert.Equal(t, handlers.RecentWindow{
|
|
Events: 8, Delivered: 1, Failed: 3,
|
|
}, stats.Last24Hours)
|
|
assert.Equal(t, "50.0%", stats.Last10Minutes.FailurePercent())
|
|
assert.Equal(t, "75.0%", stats.Last24Hours.FailurePercent())
|
|
|
|
assertStatsTargets(t, hist)
|
|
|
|
// Retention removes the oldest event with its three deliveries:
|
|
// the first target's failed one and the pending replay, and the
|
|
// second target's pending one.
|
|
statsPrune(t, db, dbMgr, log, hist.webhookDB)
|
|
|
|
after := h.WebhookStatsForTest(hist.webhook.ID)
|
|
require.NotNil(t, after)
|
|
|
|
assert.Equal(t, stats.Lifetime, after.Lifetime)
|
|
assert.Equal(t, handlers.Counts{Events: 9, Deliveries: 18, Failures: 4},
|
|
after.WithinRetention)
|
|
assert.Equal(t, int64(13), after.InProgress)
|
|
assert.Equal(t, stats.LastEventAt, after.LastEventAt)
|
|
assert.Equal(t, stats.Last10Minutes, after.Last10Minutes)
|
|
assert.Equal(t, stats.Last24Hours, after.Last24Hours)
|
|
|
|
assert.Equal(t, map[string]database.TargetTotals{
|
|
first: {
|
|
TargetID: first, Deliveries: 11, Delivered: 1, Failed: 3,
|
|
DeliveriesRemoved: 2, FailedRemoved: 1,
|
|
},
|
|
second: {
|
|
TargetID: second, Deliveries: 10, Failed: 2,
|
|
DeliveriesRemoved: 1,
|
|
},
|
|
}, statsTargetTotals(t, hist.webhookDB))
|
|
|
|
assertStatsPaneAfterPrune(t, h, sess, hist)
|
|
}
|
|
|
|
// TestWebhookStats_LastEventSurvivesPruningEveryEvent checks that once
|
|
// retention has removed every event, the pane still shows when the last
|
|
// one arrived rather than "none".
|
|
func TestWebhookStats_LastEventSurvivesPruningEveryEvent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
dbMgr *database.WebhookDBManager
|
|
log *logger.Logger
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := &database.Webhook{
|
|
UserID: deleteTestUserID, Name: "pruned", RetentionDays: 1,
|
|
}
|
|
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
|
|
|
path := statsEntrypoint(t, db, wh.ID, true)
|
|
require.Equal(t, http.StatusOK,
|
|
postReceiver(t, receiverRouter(h), path))
|
|
|
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
require.NoError(t, err)
|
|
|
|
events := listEvents(t, webhookDB)
|
|
require.Len(t, events, 1)
|
|
|
|
arrived := events[0].CreatedAt
|
|
|
|
statsAge(t, webhookDB, events[0].ID, time.Now().Add(-50*time.Hour))
|
|
statsPrune(t, db, dbMgr, log, webhookDB)
|
|
require.Empty(t, listEvents(t, webhookDB))
|
|
|
|
stats := h.WebhookStatsForTest(wh.ID)
|
|
require.NotNil(t, stats)
|
|
require.NotNil(t, stats.LastEventAt)
|
|
assert.True(t, arrived.Equal(*stats.LastEventAt))
|
|
|
|
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
|
assert.Contains(t, pane,
|
|
"Last event "+arrived.UTC().Format("2006-01-02 15:04:05 UTC"))
|
|
}
|
|
|
|
// TestWebhookStats_LastEventInUTC checks that the pane shows when the
|
|
// last event arrived in UTC, as the event list does, when the time was
|
|
// stored in another zone, as it is on a host whose local time is not
|
|
// UTC.
|
|
func TestWebhookStats_LastEventInUTC(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
dbMgr *database.WebhookDBManager
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := seedWebhook(t, db)
|
|
|
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
require.NoError(t, err)
|
|
|
|
arrived := time.Date(2026, time.March, 4, 22, 30, 0, 0,
|
|
time.FixedZone("EST", -5*60*60))
|
|
require.NoError(t, database.AddEventTotals(webhookDB,
|
|
database.EventTotals{Events: 1, LastEventAt: &arrived}))
|
|
|
|
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
|
assert.Contains(t, pane, "Last event 2026-03-05 03:30:00 UTC")
|
|
}
|
|
|
|
// TestWebhookStats_PaneShowsRetentionPeriod checks that the statistics
|
|
// pane itself, not only the line at the foot of the page, shows the
|
|
// webhook's retention period, for a finite one and for forever.
|
|
func TestWebhookStats_PaneShowsRetentionPeriod(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
tests := []struct {
|
|
retentionDays int
|
|
want string
|
|
}{
|
|
{30, "30 days"},
|
|
{database.RetentionForeverDays, "forever"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
wh := &database.Webhook{
|
|
UserID: deleteTestUserID,
|
|
Name: "retention",
|
|
RetentionDays: tt.retentionDays,
|
|
}
|
|
require.NoError(t,
|
|
db.DB().Omit(clause.Associations).Create(wh).Error)
|
|
|
|
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
|
assert.Contains(t, pane, "Retention "+tt.want)
|
|
}
|
|
}
|
|
|
|
// TestWebhookStats_WebhookWithNoEvents covers a webhook whose event
|
|
// database has never been opened: every count is zero, the
|
|
// percentages are a dash, and showing the page does not create the
|
|
// database.
|
|
func TestWebhookStats_WebhookWithNoEvents(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
h *handlers.Handlers
|
|
sess *session.Session
|
|
db *database.Database
|
|
dbMgr *database.WebhookDBManager
|
|
)
|
|
|
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
|
app.RequireStart()
|
|
|
|
t.Cleanup(app.RequireStop)
|
|
|
|
wh := seedWebhook(t, db)
|
|
|
|
assert.Equal(t, &handlers.WebhookStats{}, h.WebhookStatsForTest(wh.ID))
|
|
assert.Equal(t, "—", handlers.RecentWindow{}.FailurePercent())
|
|
|
|
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
|
assert.Contains(t, pane, "Last event none")
|
|
assert.Contains(t, pane, "Failure percentage — —")
|
|
assert.False(t, dbMgr.DBExists(wh.ID))
|
|
}
|
|
|
|
// TestRecentWindow_FailurePercent pins the percentage: failed
|
|
// deliveries out of all that finished in the window.
|
|
func TestRecentWindow_FailurePercent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
window handlers.RecentWindow
|
|
want string
|
|
}{
|
|
{handlers.RecentWindow{}, "—"},
|
|
{handlers.RecentWindow{Events: 4}, "—"},
|
|
{handlers.RecentWindow{Delivered: 3, Failed: 1}, "25.0%"},
|
|
{handlers.RecentWindow{Failed: 2}, "100.0%"},
|
|
{handlers.RecentWindow{Delivered: 2}, "0.0%"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
assert.Equal(t, tt.want, tt.window.FailurePercent(), tt.window)
|
|
}
|
|
}
|