diff --git a/models/actions/run.go b/models/actions/run.go index 79b71459351e2..fee71f1af6c81 100644 --- a/models/actions/run.go +++ b/models/actions/run.go @@ -277,6 +277,12 @@ func GetRunByRepoAndID(ctx context.Context, repoID, runID int64) (*ActionRun, er return &run, nil } +func GetRunsByRepoAndID(ctx context.Context, repoID int64, runIDs []int64) ([]*ActionRun, error) { + var runs []*ActionRun + err := db.GetEngine(ctx).In("id", runIDs).Where("repo_id=?", repoID).Find(&runs) + return runs, err +} + func GetRunByRepoAndIndex(ctx context.Context, repoID, runIndex int64) (*ActionRun, error) { run, has, err := db.Get[ActionRun](ctx, builder.Eq{"repo_id": repoID, "`index`": runIndex}) if err != nil { diff --git a/routers/web/repo/actions/actions.go b/routers/web/repo/actions/actions.go index 1bdcffab789ef..0b453b07de19a 100644 --- a/routers/web/repo/actions/actions.go +++ b/routers/web/repo/actions/actions.go @@ -8,8 +8,10 @@ import ( stdCtx "context" "errors" "fmt" + "html/template" "net/http" "net/url" + "path" "slices" "strings" @@ -85,6 +87,11 @@ func List(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("actions.actions") ctx.Data["PageIsActions"] = true + if ctx.FormBool("runs-list-only") { + ListRunsOnly(ctx) + return + } + commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) if errors.Is(err, util.ErrNotExist) { ctx.Data["NotFoundPrompt"] = ctx.Tr("repo.branch.default_branch_not_exist", ctx.Repo.Repository.DefaultBranch) @@ -114,14 +121,214 @@ func List(ctx *context.Context) { return } - prepareWorkflowList(ctx, workflows, otherWorkflows, len(scopedNames) > 0) + data := prepareWorkflowList(ctx, workflows, otherWorkflows, len(scopedNames) > 0) if ctx.Written() { return } + ctx.Data["ActionRunListData"] = data ctx.HTML(http.StatusOK, tplListActions) } +func findAndPrepareRuns(ctx *context.Context, opts actions_model.FindRunOptions) ([]*actions_model.ActionRun, int64, map[int64]string, error) { + runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts) + if err != nil { + return nil, 0, nil, err + } + + for _, run := range runs { + run.Repo = ctx.Repo.Repository + } + + if err := actions_model.RunList(runs).LoadTriggerUser(ctx); err != nil { + return nil, 0, nil, err + } + + if err := loadIsRefDeleted(ctx, ctx.Repo.Repository.ID, runs); err != nil { + log.Error("LoadIsRefDeleted: %v", err) + } + + runErrors := make(map[int64]string) + var runners []*actions_model.ActionRunner + var runnersLoaded bool + + for _, run := range runs { + if !run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked) { + continue + } + jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID) + if err != nil { + return nil, 0, nil, err + } + for _, job := range jobs { + if !job.Status.In(actions_model.StatusWaiting, actions_model.StatusBlocked) { + continue + } + if err := actions.ValidateWorkflowContent(job.WorkflowPayload); err != nil { + runErrors[run.ID] = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error()) + break + } + if job.CallUses != "" { + if _, err := actions_service.ResolveUses(ctx, job.CallUses); err != nil { + runErrors[run.ID] = ctx.Locale.TrString("actions.runs.invalid_reusable_workflow_uses", err.Error()) + break + } + } + if job.Status.IsWaiting() { + if !runnersLoaded { + runners, err = db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{ + RepoID: ctx.Repo.Repository.ID, + IsOnline: optional.Some(true), + WithAvailable: true, + }) + if err != nil { + return nil, 0, nil, err + } + runnersLoaded = true + } + hasOnlineRunner := false + for _, runner := range runners { + if !runner.IsDisabled && runner.CanMatchLabels(job.RunsOn) { + hasOnlineRunner = true + break + } + } + if !hasOnlineRunner { + runErrors[run.ID] = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", strings.Join(job.RunsOn, ",")) + break + } + } + } + } + + return runs, total, runErrors, nil +} + +func fillRefreshMetadata(ctx *context.Context, data *actionRunListData) { + // Dynamic refresh URL + refreshURL, _ := url.Parse(setting.AppSubURL + ctx.Req.RequestURI) + q := refreshURL.Query() + q.Set("runs-list-only", "true") + refreshURL.RawQuery = q.Encode() + data.RefreshLink = template.URL(refreshURL.String()) + + // Dynamic refresh interval + hasActiveRuns := false + for _, run := range data.ActionRuns { + if run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked) { + hasActiveRuns = true + break + } + } + interval := 10 * 1000 // 10s default + if hasActiveRuns { + interval = 3000 // 3s for active runs + } + if !setting.IsProd { + interval = 1000 // 1s in dev mode + } + data.RefreshIntervalMs = int64(interval) +} + +func ListRunsOnly(ctx *context.Context) { + actorID := ctx.FormInt64("actor") + status := ctx.FormInt("status") + workflowID := ctx.FormString("workflow") + scopedWorkflowSourceRepoID := ctx.FormInt64("scoped_workflow_source_repo_id") + branch := ctx.FormString("branch") + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } + + ctx.Data["CurActor"] = actorID + ctx.Data["CurStatus"] = status + ctx.Data["CurBranch"] = branch + + opts := actions_model.FindRunOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + }, + RepoID: ctx.Repo.Repository.ID, + WorkflowID: workflowID, + WorkflowRepoID: scopedWorkflowSourceRepoID, + TriggerUserID: actorID, + } + + if actions_model.Status(status) != actions_model.StatusUnknown { + opts.Status = []actions_model.Status{actions_model.Status(status)} + } + if branch != "" { + opts.Ref = string(git.RefNameFromBranch(branch)) + } + + runs, total, runErrors, err := findAndPrepareRuns(ctx, opts) + if err != nil { + ctx.ServerError("findAndPrepareRuns", err) + return + } + + data := &actionRunListData{ + ActionRuns: runs, + IsFiltered: actorID > 0 || status > int(actions_model.StatusUnknown) || branch != "", + RunErrors: runErrors, + CurWorkflow: workflowID, + CanWriteRepoUnitActions: ctx.Repo.Permission.CanWrite(unit.TypeActions), + } + + fillRefreshMetadata(ctx, data) + + // Lazy load workflow display names from default branch commit + var commit *git.Commit + var commitLoaded bool + workflowNames := make(map[string]string) + for _, run := range runs { + if run.WorkflowRepoID > 0 { + continue + } + if _, ok := workflowNames[run.WorkflowID]; !ok { + if !commitLoaded { + commit, err = ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) + if err != nil { + log.Error("GetBranchCommit: %v", err) + break + } + commitLoaded = true + } + if commit == nil { + continue + } + var workflowPath string + for _, dir := range setting.Actions.WorkflowDirs { + pathToCheck := path.Join(dir, run.WorkflowID) + if has, _ := commit.HasFile(pathToCheck); has { + workflowPath = pathToCheck + break + } + } + if workflowPath == "" { + workflowPath = path.Join(".gitea/workflows", run.WorkflowID) + } + var displayName string + if content, err := commit.GetFileContent(workflowPath, 1024*1024); err == nil { + displayName = actions.WorkflowDisplayName(workflowPath, []byte(content)) + } else { + displayName = run.WorkflowID + } + workflowNames[run.WorkflowID] = displayName + } + } + data.WorkflowNames = workflowNames + + pager := context.NewPagination(total, opts.PageSize, opts.Page, 5) + pager.AddParamFromRequest(ctx.Req) + ctx.Data["Page"] = pager + + ctx.Data["ActionRunListData"] = data + ctx.HTML(http.StatusOK, "repo/actions/runs_list") +} + // prepareOtherWorkflows surfaces historical runs whose workflow file no longer // exists on the default branch (renamed, removed, or only on other branches). func prepareOtherWorkflows(ctx *context.Context, workflows []WorkflowInfo, scopedNames container.Set[string], curWorkflowID string) []string { @@ -452,25 +659,32 @@ func prepareWorkflowDispatchTemplate(ctx *context.Context, workflowInfos []Workf ctx.Data["Tags"] = tags } -func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWorkflows []string, hasScopedWorkflows bool) { +type actionRunListData struct { + PartialRefresh bool + RefreshLink template.URL + RefreshIntervalMs int64 + ActionRuns []*actions_model.ActionRun + IsFiltered bool + WorkflowNames map[string]string + RunErrors map[int64]string + CurWorkflow string + CanWriteRepoUnitActions bool +} + +func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWorkflows []string, hasScopedWorkflows bool) (data *actionRunListData) { + data = &actionRunListData{} actorID := ctx.FormInt64("actor") status := ctx.FormInt("status") workflowID := ctx.FormString("workflow") scopedWorkflowSourceRepoID := ctx.FormInt64("scoped_workflow_source_repo_id") branch := ctx.FormString("branch") - page := ctx.FormInt("page") - if page <= 0 { - page = 1 - } + page := max(ctx.FormInt("page"), 1) // if status or actor query param is not given to frontend href, (href="//actions") // they will be 0 by default, which indicates get all status or actors ctx.Data["CurActor"] = actorID ctx.Data["CurStatus"] = status ctx.Data["CurBranch"] = branch - if actorID > 0 || status > int(actions_model.StatusUnknown) || branch != "" { - ctx.Data["IsFiltered"] = true - } opts := actions_model.FindRunOptions{ ListOptions: db.ListOptions{ @@ -488,7 +702,6 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo opts.IsScopedRun = optional.Some(scopedWorkflowSourceRepoID > 0) } - // if status is not StatusUnknown, it means user has selected a status filter if actions_model.Status(status) != actions_model.StatusUnknown { opts.Status = []actions_model.Status{actions_model.Status(status)} } @@ -496,77 +709,19 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo opts.Ref = string(git.RefNameFromBranch(branch)) } - runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts) + runs, total, runErrors, err := findAndPrepareRuns(ctx, opts) if err != nil { - ctx.ServerError("FindAndCount", err) - return + ctx.ServerError("findAndPrepareRuns", err) + return data } - for _, run := range runs { - run.Repo = ctx.Repo.Repository - } + data.ActionRuns = runs + data.IsFiltered = actorID > 0 || status > int(actions_model.StatusUnknown) || branch != "" + data.RunErrors = runErrors + data.CurWorkflow = workflowID + data.CanWriteRepoUnitActions = ctx.Repo.Permission.CanWrite(unit.TypeActions) - if err := actions_model.RunList(runs).LoadTriggerUser(ctx); err != nil { - ctx.ServerError("LoadTriggerUser", err) - return - } - - if err := loadIsRefDeleted(ctx, ctx.Repo.Repository.ID, runs); err != nil { - log.Error("LoadIsRefDeleted", err) - } - - // Check for each run if there is at least one online runner that can run its jobs - runErrors := make(map[int64]string) - runners, err := db.Find[actions_model.ActionRunner](ctx, actions_model.FindRunnerOptions{ - RepoID: ctx.Repo.Repository.ID, - IsOnline: optional.Some(true), - WithAvailable: true, - }) - if err != nil { - ctx.ServerError("FindRunners", err) - return - } - for _, run := range runs { - if !run.Status.In(actions_model.StatusWaiting, actions_model.StatusRunning, actions_model.StatusBlocked) { - continue - } - jobs, err := actions_model.GetLatestAttemptJobsByRepoAndRunID(ctx, run.RepoID, run.ID) - if err != nil { - ctx.ServerError("GetRunJobsByRunID", err) - return - } - for _, job := range jobs { - if !job.Status.In(actions_model.StatusWaiting, actions_model.StatusBlocked) { - continue - } - if err := actions.ValidateWorkflowContent(job.WorkflowPayload); err != nil { - runErrors[run.ID] = ctx.Locale.TrString("actions.runs.invalid_workflow_helper", err.Error()) - break - } - if job.CallUses != "" { - if _, err := actions_service.ResolveUses(ctx, job.CallUses); err != nil { - runErrors[run.ID] = ctx.Locale.TrString("actions.runs.invalid_reusable_workflow_uses", err.Error()) - break - } - } - if job.Status.IsWaiting() { - hasOnlineRunner := false - for _, runner := range runners { - if !runner.IsDisabled && runner.CanMatchLabels(job.RunsOn) { - hasOnlineRunner = true - break - } - } - if !hasOnlineRunner { - runErrors[run.ID] = ctx.Locale.TrString("actions.runs.no_matching_online_runner_helper", strings.Join(job.RunsOn, ",")) - break - } - } - } - } - ctx.Data["RunErrors"] = runErrors - - ctx.Data["Runs"] = runs + fillRefreshMetadata(ctx, data) workflowNames := make(map[string]string, len(workflows)) workflowDisplayName := workflowID @@ -577,7 +732,8 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo workflowDisplayName = displayName } } - ctx.Data["WorkflowNames"] = workflowNames + data.WorkflowNames = workflowNames + // A scoped workflow has no repo-level badge on this repo (the badge endpoint reads is_scoped_run=false runs), // so don't offer the "create status badge" entry for it. if scopedWorkflowSourceRepoID == 0 { @@ -587,7 +743,7 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo actors, err := actions_model.GetActors(ctx, ctx.Repo.Repository.ID) if err != nil { ctx.ServerError("GetActors", err) - return + return data } ctx.Data["Actors"] = shared_user.MakeSelfOnTop(ctx.Doer, actors) @@ -596,7 +752,7 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo runBranches, err := actions_model.GetRunBranches(ctx, ctx.Repo.Repository.ID) if err != nil { ctx.ServerError("GetRunBranches", err) - return + return data } ctx.Data["RunBranches"] = runBranches @@ -606,6 +762,8 @@ func prepareWorkflowList(ctx *context.Context, workflows []WorkflowInfo, otherWo ctx.Data["HasWorkflowsOrRuns"] = len(workflows) > 0 || len(otherWorkflows) > 0 || len(runs) > 0 || hasScopedWorkflows ctx.Data["CanWriteRepoUnitActions"] = ctx.Repo.Permission.CanWrite(unit.TypeActions) + + return data } func prepareWorkflowBadgeTemplate(ctx *context.Context, workflowID, displayName string) { diff --git a/templates/base/paginate.tmpl b/templates/base/paginate.tmpl index f6c1785ccf0b1..49374f96f322a 100644 --- a/templates/base/paginate.tmpl +++ b/templates/base/paginate.tmpl @@ -1,7 +1,8 @@ -{{$paginationParams := .Page.GetParams}} -{{$paginationLink := $.Link}} +{{$page := or $.Page ctx.RootData.Page}} +{{$paginationParams := $page.GetParams}} +{{$paginationLink := ctx.RootData.Link}} {{if eq $paginationLink AppSubUrl}}{{$paginationLink = print $paginationLink "/"}}{{end}} -{{with .Page.Paginater}} +{{with $page.Paginater}} {{if or (eq .TotalPages -1) (gt .TotalPages 1)}} {{$showFirstLast := gt .TotalPages 1}}
diff --git a/templates/repo/actions/list.tmpl b/templates/repo/actions/list.tmpl index cb1b1c7fb5e4f..fc0263cc1d738 100644 --- a/templates/repo/actions/list.tmpl +++ b/templates/repo/actions/list.tmpl @@ -153,7 +153,7 @@ {{end}}
- {{template "repo/actions/runs_list" .}} + {{template "repo/actions/runs_list" dict "ActionRunListData" $.ActionRunListData}}
diff --git a/templates/repo/actions/runs_list.tmpl b/templates/repo/actions/runs_list.tmpl index 58dc222cf5b38..0fd9e3fb3bfad 100644 --- a/templates/repo/actions/runs_list.tmpl +++ b/templates/repo/actions/runs_list.tmpl @@ -1,12 +1,16 @@ -
- {{if not .Runs}} +{{$data := .ActionRunListData}} +
+ {{if not $data.ActionRuns}}
{{svg "octicon-no-entry" 48}} -

{{if $.IsFiltered}}{{ctx.Locale.Tr "actions.runs.no_results"}}{{else}}{{ctx.Locale.Tr "actions.runs.no_runs"}}{{end}}

+

{{if $data.IsFiltered}}{{ctx.Locale.Tr "actions.runs.no_results"}}{{else}}{{ctx.Locale.Tr "actions.runs.no_runs"}}{{end}}

{{end}} - {{range $run := .Runs}} -
+ {{range $run := $data.ActionRuns}} +
{{template "repo/icons/action_status" (dict "Status" $run.Status.String "IconVariant" "circle-fill")}} @@ -15,22 +19,22 @@
{{$title := or $run.Title (ctx.Locale.Tr "actions.runs.empty_commit_message")}} - {{ctx.RenderUtils.RenderCommitMessageLinkSubject $title $run.Link $.Repository}} + {{ctx.RenderUtils.RenderCommitMessageLinkSubject $title $run.Link $run.Repo}}
- {{$workflowName := index $.WorkflowNames $run.WorkflowID}} - {{if not $.CurWorkflow}}{{if $workflowName}}{{$workflowName}}{{else}}{{$run.WorkflowID}}{{end}} {{end}}#{{$run.Index}}: + {{$workflowName := index $data.WorkflowNames $run.WorkflowID}} + {{if not $data.CurWorkflow}}{{if $workflowName}}{{$workflowName}}{{else}}{{$run.WorkflowID}}{{end}} {{end}}#{{$run.Index}}: {{- if $run.ScheduleID -}} {{ctx.Locale.Tr "actions.runs.scheduled"}} {{- else -}} {{ctx.Locale.Tr "actions.runs.commit"}} - {{ShortSha $run.CommitSHA}} + {{ShortSha $run.CommitSHA}} {{ctx.Locale.Tr "actions.runs.pushed_by"}} {{$run.TriggerUser.GetDisplayName}} {{- end -}} - {{$errMsg := index $.RunErrors $run.ID}} + {{$errMsg := index $data.RunErrors $run.ID}} {{if $errMsg}} {{svg "octicon-alert" 16 "tw-text-red"}} @@ -52,12 +56,12 @@ {{svg "octicon-kebab-horizontal"}} -{{template "base/paginate" .}} +{{template "base/paginate" dict "Page" ctx.RootData.Page}} diff --git a/types.d.ts b/types.d.ts index 68f839924ff6d..e90a21fe00eed 100644 --- a/types.d.ts +++ b/types.d.ts @@ -15,8 +15,26 @@ declare module '*.vue' { } declare module 'idiomorph' { + interface IdiomorphCallbacks { + beforeNodeAdded?(node: Node): boolean | void; + afterNodeAdded?(node: Node): void; + beforeNodeMorphed?(oldNode: Node, newNode: Node): boolean | void; + afterNodeMorphed?(oldNode: Node, newNode: Node): void; + beforeNodeRemoved?(node: Node): boolean | void; + afterNodeRemoved?(node: Node): void; + beforeAttributeUpdated?(attributeName: string, node: Node, mutationType: 'update' | 'remove'): boolean | void; + } + + interface IdiomorphOptions { + morphStyle?: 'innerHTML' | 'outerHTML'; + ignoreActive?: boolean; + ignoreActiveValue?: boolean; + restoreFocus?: boolean; + callbacks?: IdiomorphCallbacks; + } + interface Idiomorph { - morph(existing: Node | string, replacement: Node | string, options?: {morphStyle: 'innerHTML' | 'outerHTML'}): void; + morph(existing: Node | string, replacement: Node | string, options?: IdiomorphOptions): void; } export const Idiomorph: Idiomorph; } diff --git a/web_src/js/features/repo-actions.ts b/web_src/js/features/repo-actions.ts index 89ed94e078878..f56424ec6b790 100644 --- a/web_src/js/features/repo-actions.ts +++ b/web_src/js/features/repo-actions.ts @@ -1,7 +1,10 @@ import {createApp} from 'vue'; +import {Idiomorph} from 'idiomorph'; import RepoActionView from '../components/RepoActionView.vue'; import {registerGlobalInitFunc} from '../modules/observer.ts'; import {html} from '../utils/html.ts'; +import {GET} from '../modules/fetch.ts'; +import {ActivePageTimer, createElementFromHTML} from '../utils/dom.ts'; export function updateWorkflowBadgeFields(form: HTMLElement, branch: string): void { const badgeURLParsed = new URL(form.getAttribute('data-badge-url')!); @@ -27,6 +30,7 @@ function initWorkflowBadgeForm(form: HTMLElement): void { export function initRepositoryActions() { registerGlobalInitFunc('initWorkflowBadgeForm', initWorkflowBadgeForm); initRepositoryActionsView(); + registerGlobalInitFunc('initActionRunsList', initActionRunsList); } function initRepositoryActionsView() { @@ -103,3 +107,39 @@ function initRepositoryActionsView() { }); view.mount(el); } + +async function initActionRunsList(el: HTMLElement) { + let timer: ActivePageTimer; + let startRefresh: () => void; + const refresh = async (link: string) => { + const resp = await GET(link); + if (!resp.ok) { + timer.start(); // retry on network error + return; + } + const newEl = createElementFromHTML(await resp.text()); + for (const attr of newEl.attributes) el.setAttribute(attr.name, attr.value); + + Idiomorph.morph(el, newEl.innerHTML, { + morphStyle: 'innerHTML', + callbacks: { + beforeNodeMorphed: (oldNode: Node) => !( + oldNode instanceof HTMLElement && + oldNode.classList.contains('dropdown') && + (oldNode.classList.contains('active') || oldNode.classList.contains('visible')) + ), + }, + }); + + startRefresh(); + }; + startRefresh = () => { + const link = el.getAttribute('data-action-runs-refresh-link')!; + const interval = Number(el.getAttribute('data-action-runs-refresh-interval')); + if (!interval) return; + timer = new ActivePageTimer(interval, {once: true}); + timer.callback = async () => await refresh(link); + timer.start(); + }; + startRefresh(); +} diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts index 03ab783142731..fcb8f1431e786 100644 --- a/web_src/js/utils/dom.ts +++ b/web_src/js/utils/dom.ts @@ -339,3 +339,68 @@ let elemIdCounter = 0; export function generateElemId(prefix: string = ''): string { return `${prefix}${elemIdCounter++}`; } + +export type ActivePageTimerOptions = { + once: boolean; +}; + +export class ActivePageTimer { + callback?: () => Promise; + + private readonly interval: number; + private readonly opts: ActivePageTimerOptions; + private readonly onVisibilityChange: () => void; + + private sysTimerId?: number; + private startTime?: number; + + constructor(interval: number, opts: ActivePageTimerOptions = {once: false}) { + this.interval = interval; + this.opts = opts; + this.onVisibilityChange = () => { + if (!this.startTime) return; + if (document.hidden) { + this.clearSysTimer(); + } else { + this.startSysTimer(); + } + }; + } + + private async handler() { + this.clear(); + await this.callback!(); + if (!this.opts.once) this.start(); + } + + start() { + if (this.sysTimerId) return; + if (!this.startTime) { + this.startTime = Date.now(); + document.addEventListener('visibilitychange', this.onVisibilityChange); + } + if (!document.hidden) this.startSysTimer(); + } + + private startSysTimer() { + const remaining = this.interval - (Date.now() - this.startTime!); + if (remaining <= 0) { + this.handler(); + } else { + this.sysTimerId = window.setTimeout(() => this.handler(), remaining); + } + } + + private clearSysTimer() { + if (this.sysTimerId === undefined) return; + window.clearTimeout(this.sysTimerId); + this.sysTimerId = undefined; + } + + clear() { + if (this.startTime === undefined) return; + this.startTime = undefined; + this.clearSysTimer(); + document.removeEventListener('visibilitychange', this.onVisibilityChange); + } +}