check / check (push) Waiting to run
retention_days loses its column default of 30, and Webhook.RetentionDays its default tag, so a 0 is stored as 0. The 365000-day stand-in RetentionForeverDays and the BeforeSave hook that wrote it are gone. The reaper and the "forever" label read 0 as forever, and the forms accept 0 or 1 to MaxFiniteRetentionDays; a blank field still gives 30 on the new webhook form and keeps the stored value on the edit form. A negative value, which the forms refuse, is still read as forever so it cannot put the reaper's cutoff in the future. Model: opus-5-5
570 lines
16 KiB
Go
570 lines
16 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"},
|
|
{0, "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)
|
|
}
|
|
}
|