diff --git a/changelog/2026-09-23_admin_console.md b/changelog/2026-09-23_admin_console.md new file mode 100644 index 000000000..24a491172 --- /dev/null +++ b/changelog/2026-09-23_admin_console.md @@ -0,0 +1,52 @@ +# Admin console for verifier recovery (U1–U3) + +## Executive Summary + +- Adds a server-rendered admin console (templ/htmx, Gin) shipped in both verifier images and + served in-process by the verifier when a console config file is present, wrapping the + job-queue and recovery stores so operators can find, explain, and recover dropped messages + without node shell access or CLI flags. +- The console administers the verifier it runs beside — one console, one verifier. It shares + that verifier's application database and secrets file, so there is nothing extra to + provision: no console database, no per-node secrets references. +- Covers message search in the verifier's failed-job archive with lookup-failure-vs-empty + separation, a per-message detail page (failure category, archive age/expiry, durable + drop/incident evidence with coverage window, chain-status context), attestation freshness + checks (anonymous aggregator reads) gating reschedule, owner-scoped reschedule with preview + and per-target outcomes, and a durable action log. +- Source-range recovery (replay/reset-reader) is driven through the durable R5 operations with + progress, cancel/resume, and reload-safe tracking; R4 evidence is shown alongside the chosen + range. Indexer-data backfill is out of scope for now (deferred with the indexer admin UI); + indexer repair stays with the indexer's own replay tooling. +- Safety model: loopback bind by default (non-loopback requires an identity source — an + authenticating-proxy actor header or `[admin_ui]` basic auth from the verifier secrets + file), CSRF-protected mutations, and every mutation recorded with an intent row before it + runs — an unaudited mutation never proceeds. +- Console state is one Postgres table (`ccv_admin_actions`) created by the verifier's own + migrations, alongside the stores it administers. No changes to verifier runtime behavior + when the config file is absent. +- Packaging: a console config at `/etc/ccv-admin/config.toml` (`CCV_ADMIN_CONFIG_PATH`) + enables the console; both verifier factories (committee and token, so alt-VMs inherit it) + serve it in-process on its own port and shut it down with the job. No config file means + disabled. + +## AI Adapter Index + +Purely additive except for the CLI command table. Unlisted symbols keep their existing contracts. + +| Symbol | Kind | Search | Location | +| --- | --- | --- | --- | +| `admin` package (console) | added | `verifier/pkg/admin` | `verifier/pkg/admin/` | +| `cli/admin.Command` (`ccv admin check-config`) | added | `admin\.Command` | `cli/admin/commands.go` | +| `startAdminConsole` (factory wiring) | added | `startAdminConsole` | `cmd/verifier/adminconsole.go` | +| `admin.BasicAuthFromSecrets / ValidateAccessPolicy` | added | `BasicAuthFromSecrets` | `verifier/pkg/admin/auth.go` | +| `vsecrets.VerifierSecrets.AdminUIAuth` | added | `AdminUIAuth` | `verifier/pkg/vsecrets/vsecrets.go` | +| `[admin_ui]` secrets table | added | `admin_ui` | `docs/config/verifier/secrets.documented.toml` | +| `ccv_admin_actions` table | added | `00010_admin_actions` | `verifier/migrations/postgres/00010_admin_actions.sql` | + +## Compatibility + +The console administers the standalone verifier's own application database. It only uses the +live-safe operations; the offline-only `ccv chain-statuses` mutations are deliberately not +exposed. The Chainlink-node integration is untouched: the console is wired in the standalone +factories only. diff --git a/cli/admin/commands.go b/cli/admin/commands.go new file mode 100644 index 000000000..7a56cbf4c --- /dev/null +++ b/cli/admin/commands.go @@ -0,0 +1,49 @@ +// Package admin provides the `ccv admin` commands. The console itself is served +// in-process by the verifier factory when the config file is present; this group is +// for pre-flight validation of that file. +package admin + +import ( + "fmt" + + "github.com/urfave/cli" + + "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/admin" +) + +// Command returns the `ccv admin` command group. +func Command() cli.Command { + return cli.Command{ + Name: "admin", + Usage: "Admin console helpers (the console is served by the verifier process itself)", + Subcommands: []cli.Command{ + { + Name: "check-config", + Usage: "Validate the console config file the verifier would load at startup", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "config", + Usage: "Path to the console config TOML", + EnvVar: admin.ConfigPathEnv, + Value: admin.DefaultConfigPath, + }, + }, + Action: func(c *cli.Context) error { + cfg, err := admin.LoadConfig(c.String("config")) + if err != nil { + return err + } + access := "actor local (loopback)" + if cfg.Access.ActorHeader != "" { + access = "proxy header " + cfg.Access.ActorHeader + } + fmt.Println("config OK: listen=" + cfg.ListenAddress + " access=" + access) //nolint:forbidigo // CLI user output + if cfg.AggregatorAddress != "" { + fmt.Println(" attestation freshness checks via aggregator " + cfg.AggregatorAddress) //nolint:forbidigo // CLI user output + } + return nil + }, + }, + }, + } +} diff --git a/cli/recovery/README.md b/cli/recovery/README.md index eb93a81b5..51460c4fb 100644 --- a/cli/recovery/README.md +++ b/cli/recovery/README.md @@ -1,6 +1,8 @@ # CCV live recovery CLI -The standalone verifier accepts durable recovery requests through its existing PostgreSQL database. The running source reader performs the work on its event loop. There is no admin HTTP endpoint or UI in this change. These commands require a binary and schema containing migration 00009; the existing verifier migration mechanism applies it during upgrade. Chainlink core must separately expose this command group before it is available through `chainlink node`. +The standalone verifier accepts durable recovery requests through its existing PostgreSQL database. The running source reader performs the work on its event loop. These commands require a binary and schema containing migration 00009; the existing verifier migration mechanism applies it during upgrade. Chainlink core must separately expose this command group before it is available through `chainlink node`. + +A server-rendered admin console wrapping these flows is served in-process by the standalone verifier when its config file is present; see `docs/verifier/admin-console.md`. Idle readers check for new recovery operations every 15 seconds (`sourcereader.RecoveryPollInterval`), so a submission can take up to that long to be picked up; an active operation runs at full event-loop speed. The coarse idle cadence keeps the control-plane database reads negligible. diff --git a/cmd/verifier/adminconsole.go b/cmd/verifier/adminconsole.go new file mode 100644 index 000000000..aad36cec2 --- /dev/null +++ b/cmd/verifier/adminconsole.go @@ -0,0 +1,64 @@ +package verifier + +import ( + "context" + "fmt" + "os" + "sync" + + "github.com/jmoiron/sqlx" + + "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/admin" + "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/vsecrets" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/sqlutil" +) + +// startAdminConsole serves the admin console in-process when its config file is +// present (CCV_ADMIN_CONFIG_PATH or /etc/ccv-admin/config.toml); the console shares +// the verifier's application DB and its [admin_ui] credential. Absent file means +// disabled (nil, nil); the returned stop function shuts the console down. +func startAdminConsole(lggr logger.Logger, ds sqlutil.DataSource, secrets *vsecrets.VerifierSecrets, aggregatorAddress string) (func(), error) { + path := os.Getenv(admin.ConfigPathEnv) + if path == "" { + path = admin.DefaultConfigPath + } + if _, err := os.Stat(path); err != nil { //nolint:gosec // G703: operator-provided config path, not request input. + return nil, nil + } + cfg, err := admin.LoadConfig(path) + if err != nil { + return nil, err + } + db, ok := ds.(*sqlx.DB) + if !ok || db == nil { + return nil, fmt.Errorf("admin console requires the verifier application database ([db].url in the verifier secrets file)") + } + auth, err := admin.BasicAuthFromSecrets(secrets) + if err != nil { + return nil, err + } + if cfg.AggregatorAddress == "" { + cfg.AggregatorAddress = aggregatorAddress + } + srv, err := admin.NewServer(cfg, admin.Deps{DB: db, Auth: auth, AggregatorAddress: cfg.AggregatorAddress}, lggr) + if err != nil { + return nil, err + } + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + if err := srv.Run(ctx); err != nil { + lggr.Errorw("admin console stopped with error", "error", err) + } + }() + var once sync.Once + return func() { + once.Do(func() { + cancel() + <-done + }) + }, nil +} diff --git a/cmd/verifier/run_ccv_cli.go b/cmd/verifier/run_ccv_cli.go index 7f492597b..2d75c5c22 100644 --- a/cmd/verifier/run_ccv_cli.go +++ b/cmd/verifier/run_ccv_cli.go @@ -10,6 +10,7 @@ import ( "go.uber.org/zap" "go.uber.org/zap/zapcore" + "github.com/smartcontractkit/chainlink-ccv/cli/admin" "github.com/smartcontractkit/chainlink-ccv/cli/chainstatuses" "github.com/smartcontractkit/chainlink-ccv/cli/jobqueue" "github.com/smartcontractkit/chainlink-ccv/cli/migrate" @@ -115,6 +116,7 @@ func RunCCVCLI(args []string, secretsEnvVar, defaultSecretsPath string) { Usage: "CCV-related commands", Subcommands: []cli.Command{ {Name: "recovery", Usage: "Live source-range recovery and durable admission evidence", Subcommands: recoverycli.InitCommandsWithFactory(getRecoveryStore)}, + admin.Command(), { Name: "chain-statuses", Usage: "List, enable, disable, or set finalized block height for chain statuses", diff --git a/cmd/verifier/servicefactory.go b/cmd/verifier/servicefactory.go index 5fa801feb..eb99df720 100644 --- a/cmd/verifier/servicefactory.go +++ b/cmd/verifier/servicefactory.go @@ -52,6 +52,7 @@ type factory struct { aggregatorWriter *storageaccess.FanOutWriter heartbeatClient heartbeatclient.HeartbeatSender chainStatusDB sqlutil.DataSource + adminStop func() } var _ bootstrap.ServiceFactoryValidator = (*factory)(nil) @@ -473,6 +474,18 @@ func (f *factory) Start(ctx context.Context, spec bootstrap.JobSpec, deps bootst f.server = server f.coordinator = coordinator + // The admin console serves in-process when its config file is present; it shares + // this verifier's application database and secrets. + aggregatorAddress := "" + if len(resolvedAggregators) > 0 { + aggregatorAddress = resolvedAggregators[0].Address + } + adminStop, err := startAdminConsole(lggr, chainStatusDB, secrets, aggregatorAddress) + if err != nil { + return fmt.Errorf("failed to start admin console: %w", err) + } + f.adminStop = adminStop + lggr.Infow("🎯 Verifier service fully started and ready!") return nil @@ -531,11 +544,17 @@ func (f *factory) Stop(ctx context.Context) error { } } + // Stop the admin console + if f.adminStop != nil { + f.adminStop() + } + f.server = nil f.coordinator = nil f.profiler = nil f.aggregatorWriter = nil f.heartbeatClient = nil + f.adminStop = nil f.lggr = nil f.chainStatusDB = nil diff --git a/cmd/verifier/tokenfactory.go b/cmd/verifier/tokenfactory.go index 1acbdb210..149be77ad 100644 --- a/cmd/verifier/tokenfactory.go +++ b/cmd/verifier/tokenfactory.go @@ -36,6 +36,7 @@ type tokenVerifierFactory struct { coordinators []*verifier.Coordinator httpServer *http.Server + adminStop func() lggr logger.Logger } @@ -49,6 +50,10 @@ func NewTokenVerifierServiceFactory() bootstrap.ServiceFactory { // Stop tries to stop all services gracefully. func (tvf *tokenVerifierFactory) Stop(_ context.Context) error { var errs []error + if tvf.adminStop != nil { + tvf.adminStop() + tvf.adminStop = nil + } if tvf.httpServer != nil { // Graceful shutdown shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) @@ -131,6 +136,14 @@ func (tvf *tokenVerifierFactory) Start(ctx context.Context, spec bootstrap.JobSp return fmt.Errorf("failed to connect to Postgres database: %w", err) } + // The admin console serves in-process when its config file is present; it shares + // this verifier's application database and secrets. The token verifier has no + // aggregator of its own, so freshness checks need aggregator_address in the file. + tvf.adminStop, err = startAdminConsole(tvf.lggr, db, secrets, "") + if err != nil { + return fmt.Errorf("failed to start admin console: %w", err) + } + postgresStorage := storage.NewPostgres(db, tvf.lggr) // Wrap storage with monitoring decorator to track query durations monitoredStorage := storage.NewMonitoredStorage(postgresStorage, verifierMonitoring.Metrics()) diff --git a/docs/config/README.md b/docs/config/README.md index a9133cd70..8c38818fc 100644 --- a/docs/config/README.md +++ b/docs/config/README.md @@ -13,6 +13,7 @@ This directory holds the config and secrets reference for every CCV app, one | indexer | `indexer/config.documented.toml`, `indexer/secrets.documented.toml` | | bootstrap | `bootstrap/config.documented.toml`, `bootstrap/secrets.documented.toml` | | monitoring (shared) | `common/monitoring.documented.toml` | +| admin console | `admin-console/config.documented.toml` | Each file is a working TOML document: the values are the app's defaults where a default exists, and illustrative examples otherwise, and every field is annotated diff --git a/docs/config/admin-console/config.documented.toml b/docs/config/admin-console/config.documented.toml new file mode 100644 index 000000000..0b2edf6fb --- /dev/null +++ b/docs/config/admin-console/config.documented.toml @@ -0,0 +1,23 @@ +# Code generated by tools/configdoc. DO NOT EDIT. +# Admin console configuration reference. Values shown are defaults or illustrative examples. + +# listen_address is the bind address; loopback by default. +listen_address = "127.0.0.1:8105" + +# aggregator_address (optional, host:port) overrides the aggregator used for +# attestation freshness checks via the unauthenticated GetVerifierResultsForMessage. +# Empty uses the verifier's own first configured aggregator. +aggregator_address = "aggregator-1:50051" + +# trace_url (optional) is a base URL to the operator's trace viewer — typically an +# internal Grafana/Tempo or Jaeger — linked from the message detail page when set. +trace_url = "https://traces.example.com" + +# access configures how the console identifies who is acting. +[access] + # actor_header names the HTTP header carrying an authenticated identity from a + # fronting proxy (shared hosting). Empty means self-hosted loopback: actor "local". + # Non-loopback serving requires this header or [admin_ui] basic auth from the + # verifier secrets file (validated at startup, when the secrets are loaded). + actor_header = "X-Authenticated-User" + diff --git a/docs/config/verifier/secrets.documented.toml b/docs/config/verifier/secrets.documented.toml index 27d70c68b..f82c99300 100644 --- a/docs/config/verifier/secrets.documented.toml +++ b/docs/config/verifier/secrets.documented.toml @@ -21,3 +21,12 @@ # secret_key is the HMAC secret the request signature is computed with. secret_key = "" +# admin_ui is the optional basic-auth credential gating the admin console UI. Only the +# admin console consumes it, when this file is the console's secrets file; the verifier +# binaries ignore it. +[admin_ui] + # username is the basic-auth username; it also becomes the action-log actor. + username = "operator" + # password is the basic-auth password. + password = "" + diff --git a/docs/runbooks/remediating-stuck-or-dropped-messages.md b/docs/runbooks/remediating-stuck-or-dropped-messages.md index 3e66cf06e..b9c41aa8c 100644 --- a/docs/runbooks/remediating-stuck-or-dropped-messages.md +++ b/docs/runbooks/remediating-stuck-or-dropped-messages.md @@ -1,8 +1,10 @@ # Runbook: Remediating a Stuck or Dropped Message -_Last reviewed: 2026-09-10._ +_Last reviewed: 2026-09-23._ -Use after [unverified-message triage](./unverified-message-after-15-minutes.md) or [unexecuted-message triage](./unexecuted-message-after-15-minutes.md) identifies the affected owner, source and messages. Recovery is per affected committee member and database. Cross-node discovery/fan-out remains an operator or deployment-layer responsibility. +Use after [unverified-message triage](./unverified-message-after-15-minutes.md) or [unexecuted-message triage](./unexecuted-message-after-15-minutes.md) identifies the affected owner, source and messages. Recovery is per affected committee member and database. + +When the [admin console](../verifier/admin-console.md) is deployed, it is the primary path: each verifier serves its own console in-process, driving every action below from the browser and recording each mutation in its action log. The console administers the one verifier it runs beside, so repeat the flow per affected committee member; the CLI steps in this runbook remain the documented fallback. ## 1. Pick the Lever @@ -17,6 +19,8 @@ Use after [unverified-message triage](./unverified-message-after-15-minutes.md) **Reschedule uses the saved payload and skips source-reader finality, curse and disablement admission checks.** It is unsuitable for deciding whether an event remains canonical after a reorg. Source recovery re-reads events that still exist on the chain and enters ordinary verification/policy processing after admission. Neither path bypasses policy. Indexer backfill refreshes the indexer's view of results; it does not re-admit verifier source events or retry policy decisions. +In plain language: use a **reschedule** when the verifier already holds the message — a failed job retained in its archive — and the fix is to run verification and policy (or just persistence) again on the saved payload. Use **source replay** when the verifier never admitted the message (curse/rule drop, missed interval, expired archive) and the source chain must be re-read to decide. Use the **investigated reader reset** for the replay special case of a finality-disabled reader, and **indexer backfill** when the verifier and aggregator are fine and only the indexer's view needs repair (the indexer's own replay tooling; not a console action). [The admin console guide](../verifier/admin-console.md#the-recovery-actions) walks through what each action does and does not do; in the console these are the actions on the message detail and source recovery pages rather than CLI invocations. + ## 2. Check the Time Windows Automatic retry remains **7 days**, with non-retryable failures (including policy FAIL) archived immediately. Archive retention remains **30 days after archiving**, swept every 4 hours. The message's creation time does not start that retention window. @@ -40,6 +44,8 @@ Drop evidence is separate from archives. It is retained for 30 days since its la ## 3. Reschedule a Single Dropped Message +**Console path:** search the full message ID on the console's message search page, open the message detail, and use the reschedule action there. The preview shows the exact owners and jobs the reschedule will touch and rechecks attestation state before anything mutates; the action is recorded in the console's action log. The CLI steps below are the fallback. + 1. Resolve the cause first. A policy endpoint must return PASS for the message before replay can succeed. Confirm that the source event remains valid and the message has not already been attested through another path. 2. Point the CLI at the affected member's database and find the full message IDs: @@ -65,6 +71,8 @@ See the [job-queue command reference](../../cli/jobqueue/README.md) and [policy ## 4. Recover a Source Range +**Console path:** the console's source recovery page queries the same drop/incident evidence and submits an ordinary replay or an investigated reader reset with the actor filled from your session and an evidence note required. Operations are durable, so progress, cancel and resume survive page reloads and console restarts. The CLI steps below are the fallback and remain the reference for exact semantics. + ### Establish the scope Identify each affected owner/node and source chain, then query retained evidence: @@ -144,4 +152,4 @@ Use [aggregator message-disablement rules](../../aggregator/cli/messagedisableme ## 6. Deployment and Coverage Limits -The new recovery/job-queue commands are exposed by the standalone verifier. Wiring them into Chainlink core, cross-node fan-out, indexer engine changes and an admin UI are outside this change. Owner inference is local to one selected archive queue/database; source recovery always requires an explicit owner. There is no per-message policy bypass. Keep canonical-chain investigation and final-result verification in the operator workflow. +The new recovery/job-queue commands are exposed by the standalone verifier. Wiring them into Chainlink core and indexer engine changes are outside this change; the [admin console](../verifier/admin-console.md) now provides the UI over these flows for the verifier it runs beside. Owner inference is local to one selected archive queue/database; source recovery always requires an explicit owner. There is no per-message policy bypass. Keep canonical-chain investigation and final-result verification in the operator workflow. diff --git a/docs/verifier/admin-console.md b/docs/verifier/admin-console.md new file mode 100644 index 000000000..0b34e0c06 --- /dev/null +++ b/docs/verifier/admin-console.md @@ -0,0 +1,231 @@ +# The CCV admin console + +The admin console is a small web UI for finding and recovering dropped messages, shipped +inside the verifier image and served **in-process** by the verifier itself. When the +container has a console config at `/etc/ccv-admin/config.toml` (override with +`CCV_ADMIN_CONFIG_PATH`), the verifier serves the console on its own port; no config +file means the console stays off. To enable it, add the config file (and optionally the +`[admin_ui]` credential) and restart the pod. + +It is a server-rendered UI (templ/htmx) over the verifier's own application database — +the console administers the verifier it runs beside, and only that verifier. It drives +the same recovery machinery as the `ccv job-queue` and `ccv recovery` CLIs, with the +same semantics. What it replaces is the manual part of those flows: pointing a CLI at +the database, copying message IDs and owner IDs between commands, and keeping your own +notes about who did what. The console searches the failed-job archive, shows what +happened to a message, executes the recovery action, and records it in an action log. +The [remediation runbook](../runbooks/remediating-stuck-or-dropped-messages.md) reads +console-first; the CLI remains the documented fallback. + +The console administers **verifier databases only** (committee and token verifiers). +Indexer-data backfill and other admin UIs are deliberately out of scope for now: repair +indexer records with the indexer's own replay tooling until that workflow ships. + +What it does not change is the semantics: a reschedule from the console is the same +reschedule the CLI performs, against the same tables, with the same limits. + +## Safety model + +The console is a privileged tool: anyone who can load a page can, in principle, run a +recovery action against your verifier. The defaults assume it is a personal operator +tool, and anything beyond that is an explicit, validated choice. + +- **Loopback by default.** `listen_address` defaults to `127.0.0.1:8105`. Reach it with + an SSH port forward (`ssh -L 8105:127.0.0.1:8105 `) and act as actor `local`. +- **Non-loopback requires an identity source.** Serving a page grants privileged + actions, so the console refuses to start on a non-loopback address unless either + `access.actor_header` is set (an authenticating proxy writes the header) or + `[admin_ui]` basic auth is configured in the verifier secrets file (the console + verifies the credential itself). See [Shared hosting](#shared-hosting-and-the-access-model). +- **Optional basic auth.** `[admin_ui]` username + password in the verifier secrets file + gates every page except `/healthz` (kept open for probes); the authenticated username + becomes the action-log actor. A half-supplied pair is a startup error, never a silent + downgrade to unauthenticated serving. +- **No new credentials or databases.** The console shares the verifier's application + database and its secrets file; there is nothing extra to provision. Database URLs are + never rendered into a page or logged. +- **Mutations are CSRF-protected.** Every state-changing request must carry the + per-browser token (form field `csrf_token` or header `X-CSRF-Token`) matching the + `ccv_admin_csrf` cookie. Pages also ship restrictive security headers + (`Content-Security-Policy: default-src 'self'`, `X-Frame-Options: DENY`, + `Referrer-Policy: no-referrer`). +- **Every mutation is recorded.** Actions are written to the `ccv_admin_actions` table + in the verifier's application database with actor, target, outcome and detail. Each + mutation writes an intent row (`outcome=started`) before touching anything, then an + outcome row after it; a mutation that cannot be logged does not proceed — an + unaudited privileged action never runs silently. + +## Setup + +Add `/etc/ccv-admin/config.toml` (path override: `CCV_ADMIN_CONFIG_PATH`) and restart +the verifier. The file is decoded strictly: unknown keys are a startup error, and a +present-but-malformed file fails startup. The minimal config is empty — every field is +optional: + +```toml +# /etc/ccv-admin/config.toml +listen_address = "127.0.0.1:8105" # the default; shown for clarity +``` + +Optional fields: + +| Field | What it enables | +| --- | --- | +| `aggregator_address` (host:port) | Overrides the aggregator used for attestation freshness checks (`GetVerifierResultsForMessage`). Default: the verifier's own first configured aggregator; a token verifier has none, so set it here if you want freshness checks. | +| `trace_url` (base URL) | Your trace viewer (e.g. an internal Grafana/Tempo or Jaeger), linked from the message detail page. | +| `access.actor_header` | The authenticated-identity header written by your fronting proxy; see [Shared hosting](#shared-hosting-and-the-access-model). | + +Basic auth, if you want it, goes in the verifier secrets file — the same file the +verifier process loads +([reference](../config/verifier/secrets.documented.toml)): + +```toml +# +[admin_ui] + username = "operator" + password = "" +``` + +### Validate before restarting + +```sh +verifier ccv admin check-config --config /etc/ccv-admin/config.toml +``` + +`check-config` runs the same loading and validation as startup and prints the listen +address and access mode. Run it after every config change — it catches misspelled keys +and malformed files before the verifier does it at startup. + +## The recovery actions + +Each action below is the console form of the corresponding CLI flow and inherits its +semantics and limits. Links go to the CLI references, which remain authoritative. + +### Verification reschedule + +For a message whose verification job failed and sits in the archive — the classic case +being a policy endpoint that answered FAIL and has since been cleared. The console +restores the archived job to the active queue; the running verifier picks it up on its +next queue poll (normally within about 30 seconds) and **runs verification and the +policy call again** on the saved payload. This is the supported FAIL-then-clears path +from the [policy hook guide](../../verifier/docs/policy_hook.md): clearing your endpoint +does not bring a message back on its own; rescheduling asks the endpoint again, and the +second call can answer PASS. + +The console previews the exact owners and jobs a reschedule will touch and rechecks +attestation state before mutating — a message that already has a result is not a +reschedule candidate. Execution is one owner-scoped operation per target, reported per +target. The archive-row and attestation gate re-runs on **every** execution — a direct +execute post, a retry, or a preview that has gone stale — and each mutation writes its +action-log intent row before it runs; a retry resubmits only the targets that failed or +were skipped. + +What it does **not** do: re-read the source event, or repeat source-reader finality, +curse or disablement admission checks. It cannot tell you whether the event is still +canonical after a reorg — that is source replay's job. It never bypasses policy: the +endpoint is asked again and can FAIL again. And it works only while the archive row is +retained: automatic retry runs for 7 days, failed rows are archived immediately, and +archives are deleted 30 days after archiving (swept every 4 hours). An expired archive +row can no longer be rescheduled; use source replay. + +### Storage reschedule + +Verification completed and a result was saved, but delivering it to storage failed. The +reschedule restores the `storage-writer` job and **only persistence runs again** — the +saved result is delivered as-is. The same limits apply: no source re-read, no admission +re-checks, and no effect once the archive row has expired. + +### Source-range replay + +For messages that never entered the queue: dropped before admission by a curse or a +disablement rule, missed while the reader was down, or aged out of the archive. The +console submits a durable recovery operation for an **inclusive source block range**; +the live source reader re-reads that range from the chain and re-runs full admission — +event filter, message-ID validation, curse check, disablement rules, finality — then +publishes ordinary verification tasks, so normal verification and policy processing +apply to whatever it finds. No policy or chain-specific bypass exists on this path. + +Submission requires block bounds and an **evidence note** (the incident reference and +why the range is being replayed); the actor is taken from your session. Bounds are fixed +at submission and never follow the moving head. The operation is durable: you can watch +progress, counters and `last_error` on the recovery page, and cancel/resume across +reloads and restarts. Work is bounded — chunks of at most 100 blocks and 1,000 +events, one chunk per owner at a time, normal traffic continues, and the normal reader +checkpoint is never rewound by an ordinary replay. + +What it does **not** do: admit an event that fails current admission checks (a still- +cursed source stays dropped — clear the root cause first), reconcile old failed archive +rows against new attestations, or release a finality-disabled reader. `completed` means +the range's queue work committed; confirm the affected messages' final attestations +separately, exactly as in the CLI flow. + +### Investigated reader reset + +The special case of replay for a reader **disabled by a finality violation or disabled +at startup**. Ordinary replay never clears a disablement; the reset is an explicit, +recorded operator decision about canonical history. You establish the known-good +boundary out of band (compare stored/observed hashes against canonical RPC headers — the +first detected mismatch may be later than the earliest affected block), then submit the +reset with your evidence note. The console seeds a fresh finality checker at +`from-block - 1` and the reset **owns normal polling until its range completes**; +cancelling or failing it keeps that pause deliberately, and resuming the same operation +finishes it. A later finality violation stays sticky and needs a **new** investigated +reset — resuming an old applied reset cannot clear it. Published jobs and previous +attestations are never deleted by a reset; there is no automatic undo of prior results. + +## Operations + +**Upgrades.** The console ships in the verifier image and runs in the verifier process, +so it upgrades when your verifier image does — there is nothing separate to deploy. +Recovery actions submitted through it take effect on the running verifier (a restored +job is picked up on the queue's fallback poll). Run the console from the same image +version as the verifier it administers: recovery features need the schema that carries +them, and the console's action table is created by the verifier's own migrations. + +**Console state.** The action log (`ccv_admin_actions`) lives in the verifier's +application database and is created by the verifier's migrations; there is no separate +console database to provision, back up, or migrate. + +**Health.** `GET /healthz` returns `200 {"status":"ok"}`. It is a process liveness +check only. + +**Config checks.** `verifier ccv admin check-config` validates the config file without +starting anything. + +## Shared hosting and the access model + +On loopback, every action is recorded as actor `local` — appropriate for a personal tool +reached over SSH. A shared deployment needs an identity source; the console refuses to +start on a non-loopback address (including a wildcard bind) unless at least one is +configured: + +- **`access.actor_header` (authenticating proxy).** The console trusts the configured + header verbatim; its value becomes the actor in the action log. +- **`[admin_ui]` basic auth (verifier secrets file).** The console verifies the + credential itself on every request except `/healthz` (kept open for probes), and the + username becomes the actor. No proxy is required for identity — but basic auth carries + the password base64-encoded, so serve it over TLS (or keep the console on loopback and + SSH-forward). When both are configured, the basic-auth username wins: the header is + client-supplied, the basic-auth credential is not. + +When the proxy is the identity source, two requirements fall on the proxy, because the +console trusts the header verbatim: + +1. The proxy must be the **only** network path to the console's listen address — anyone + who can reach the port directly can set any actor. +2. The proxy must **strip or overwrite** the configured header on inbound requests + before authenticating, so a client cannot supply its own identity. A request that + arrives without the header is served as actor `unknown`; treat `unknown` entries in + the action log as a proxy misconfiguration and fix it. + +Startup validation enforces the floor: a non-loopback `listen_address` with neither +`access.actor_header` nor `[admin_ui]` fails to start. Everything above that floor is +proxy hygiene (or basic auth over TLS). + +## See also + +- [Runbook: remediating a stuck or dropped message](../runbooks/remediating-stuck-or-dropped-messages.md) — the operational sequence, console-first. +- [Job-queue CLI reference](../../cli/jobqueue/README.md) — reschedule semantics, retention windows, owner resolution. +- [Live recovery CLI reference](../../cli/recovery/README.md) — replay and reset semantics, drop evidence, coverage limits. +- [Policy hook guide](../../verifier/docs/policy_hook.md) — the FAIL-then-clears flow a verification reschedule drives. +- [Config reference](../config/admin-console/config.documented.toml) — every config key, annotated. diff --git a/go.mod b/go.mod index 0d5a70106..c2010539c 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ replace github.com/fbsobreira/gotron-sdk => github.com/smartcontractkit/chainlin require ( cloud.google.com/go/kms v1.33.0 github.com/BurntSushi/toml v1.6.0 + github.com/a-h/templ v0.3.1020 github.com/aws/aws-sdk-go-v2/service/kms v1.54.0 github.com/beevik/ntp v1.5.0 github.com/ethereum/go-ethereum v1.17.4 diff --git a/go.sum b/go.sum index 5313b380b..1c5620b92 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,8 @@ github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMG github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= github.com/XSAM/otelsql v0.42.0 h1:Li0xF4eJUxG2e0x3D4rvRlys1f27yJKvjTh7ljkUP5o= github.com/XSAM/otelsql v0.42.0/go.mod h1:4mOrEv+cS1KmKzrvTktvJnstr5GtKSAK+QHvFR9OcpI= +github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= +github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM= github.com/allegro/bigcache v1.2.1 h1:hg1sY1raCwic3Vnsvje6TT7/pnZba83LeFck5NrFKSc= github.com/allegro/bigcache v1.2.1/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= github.com/apache/arrow-go/v18 v18.6.0 h1:GX/Jyd3R7mCLiECAwY9FWbbaYblie2WXBSz4Sw8fNpM= diff --git a/integration/storageaccess/aggregator_results_client.go b/integration/storageaccess/aggregator_results_client.go new file mode 100644 index 000000000..ae3ad16d5 --- /dev/null +++ b/integration/storageaccess/aggregator_results_client.go @@ -0,0 +1,83 @@ +package storageaccess + +import ( + "context" + "crypto/tls" + "fmt" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + + verifierpb "github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier/v1" +) + +// ResultEntry is one message ID's slot in the aggregator's batch +// GetVerifierResultsForMessage response, decoded from protobuf so callers outside +// this package never import the chainlink protos (depguard forbids them in +// verifier/ and executor/ packages). +type ResultEntry struct { + // Present is false when the response carried neither a result nor an error + // entry for this index. + Present bool + // ErrorCode is the per-ID error entry's status code; codes.OK (0) when the + // aggregator returned a result instead. + ErrorCode int32 + // ErrorMsg is the per-ID error entry's message, if any. + ErrorMsg string + // CcvData is the result's ccv data; non-empty only when the aggregator holds + // attested data for the message. + CcvData []byte +} + +// ResultsClient is the aggregator's unauthenticated verifier-results read API. +type ResultsClient interface { + // GetVerifierResultsForMessage returns one ResultEntry per requested message ID, + // index-aligned with messageIDs. + GetVerifierResultsForMessage(ctx context.Context, messageIDs [][]byte) ([]ResultEntry, error) + // Close releases the underlying connection. + Close() error +} + +// DialResultsClient opens the aggregator's unauthenticated read path: TLS transport +// credentials, no auth interceptor. +func DialResultsClient(address string) (ResultsClient, error) { + conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12}))) + if err != nil { + return nil, fmt.Errorf("failed to connect to aggregator: %w", err) + } + return &aggregatorResultsClient{client: verifierpb.NewVerifierClient(conn), conn: conn}, nil +} + +type aggregatorResultsClient struct { + client verifierpb.VerifierClient + conn *grpc.ClientConn +} + +func (c *aggregatorResultsClient) GetVerifierResultsForMessage(ctx context.Context, messageIDs [][]byte) ([]ResultEntry, error) { + resp, err := c.client.GetVerifierResultsForMessage(ctx, &verifierpb.GetVerifierResultsForMessageRequest{MessageIds: messageIDs}) + if err != nil { + return nil, err + } + entries := make([]ResultEntry, len(messageIDs)) + for i := range entries { + entries[i] = resultEntryAt(resp, i) + } + return entries, nil +} + +func (c *aggregatorResultsClient) Close() error { return c.conn.Close() } + +// resultEntryAt decodes entry i of the batch response: a per-ID error status other +// than OK means "not found"; only a result with non-empty ccv data is an attestation. +func resultEntryAt(resp *verifierpb.GetVerifierResultsForMessageResponse, i int) ResultEntry { + if i < len(resp.GetErrors()) { + if st := resp.GetErrors()[i]; st != nil && st.GetCode() != int32(codes.OK) { + return ResultEntry{Present: true, ErrorCode: st.GetCode(), ErrorMsg: st.GetMessage()} + } + } + if i < len(resp.GetResults()) { + return ResultEntry{Present: true, CcvData: resp.GetResults()[i].GetCcvData()} + } + return ResultEntry{} +} diff --git a/integration/storageaccess/aggregator_results_client_test.go b/integration/storageaccess/aggregator_results_client_test.go new file mode 100644 index 000000000..718c87815 --- /dev/null +++ b/integration/storageaccess/aggregator_results_client_test.go @@ -0,0 +1,112 @@ +package storageaccess + +import ( + "context" + "errors" + "net" + "testing" + + "github.com/stretchr/testify/require" + rpcstatus "google.golang.org/genproto/googleapis/rpc/status" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" + + verifierpb "github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier/v1" +) + +// fakeVerifierServer answers per-ID lookups: results carry ccv_data, perErr forces a +// per-ID error entry, everything else defaults to NotFound — mirroring the +// aggregator's 1:1 results/errors correspondence. +type fakeVerifierServer struct { + verifierpb.UnimplementedVerifierServer + results map[string][]byte // string(messageID) → ccv_data + perErr map[string]*rpcstatus.Status + callErr error + empty bool // return a response with no entries at all +} + +func (f *fakeVerifierServer) GetVerifierResultsForMessage(_ context.Context, req *verifierpb.GetVerifierResultsForMessageRequest) (*verifierpb.GetVerifierResultsForMessageResponse, error) { + if f.callErr != nil { + return nil, f.callErr + } + if f.empty { + return &verifierpb.GetVerifierResultsForMessageResponse{}, nil + } + resp := &verifierpb.GetVerifierResultsForMessageResponse{} + for _, id := range req.GetMessageIds() { + if st, ok := f.perErr[string(id)]; ok { + resp.Results = append(resp.Results, nil) + resp.Errors = append(resp.Errors, st) + continue + } + if ccvData, ok := f.results[string(id)]; ok { + resp.Results = append(resp.Results, &verifierpb.VerifierResult{CcvData: ccvData}) + resp.Errors = append(resp.Errors, &rpcstatus.Status{Code: int32(codes.OK)}) + continue + } + resp.Results = append(resp.Results, nil) + resp.Errors = append(resp.Errors, &rpcstatus.Status{Code: int32(codes.NotFound), Message: "message ID not found"}) + } + return resp, nil +} + +// bufconnResultsClient serves srv over bufconn and returns a ResultsClient wired to it. +func bufconnResultsClient(t *testing.T, srv verifierpb.VerifierServer) ResultsClient { + t.Helper() + lis := bufconn.Listen(1024 * 1024) + grpcSrv := grpc.NewServer() + verifierpb.RegisterVerifierServer(grpcSrv, srv) + go func() { _ = grpcSrv.Serve(lis) }() + t.Cleanup(grpcSrv.Stop) + + conn, err := grpc.NewClient("passthrough:///bufnet", + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { return lis.DialContext(ctx) }), + grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + return &aggregatorResultsClient{client: verifierpb.NewVerifierClient(conn), conn: conn} +} + +func TestResultsClientEntryTranslation(t *testing.T) { + attested := []byte{0xde, 0xad} + srv := &fakeVerifierServer{ + results: map[string][]byte{"id-attested": attested, "id-empty": {}}, + perErr: map[string]*rpcstatus.Status{"id-err": {Code: int32(codes.NotFound), Message: "message ID not found"}}, + } + client := bufconnResultsClient(t, srv) + + entries, err := client.GetVerifierResultsForMessage(context.Background(), + [][]byte{[]byte("id-attested"), []byte("id-err"), []byte("id-empty")}) + require.NoError(t, err) + require.Len(t, entries, 3) + + require.True(t, entries[0].Present) + require.Equal(t, int32(codes.OK), entries[0].ErrorCode) + require.Equal(t, attested, entries[0].CcvData) + + require.True(t, entries[1].Present) + require.Equal(t, int32(codes.NotFound), entries[1].ErrorCode) + require.Equal(t, "message ID not found", entries[1].ErrorMsg) + require.Empty(t, entries[1].CcvData) + + require.True(t, entries[2].Present) + require.Empty(t, entries[2].CcvData, "empty ccv data stays an empty result entry") +} + +func TestResultsClientMissingEntry(t *testing.T) { + client := bufconnResultsClient(t, &fakeVerifierServer{empty: true}) + + entries, err := client.GetVerifierResultsForMessage(context.Background(), [][]byte{[]byte("id-any")}) + require.NoError(t, err) + require.Len(t, entries, 1) + require.False(t, entries[0].Present, "a short batch response leaves the entry unpresent") +} + +func TestResultsClientCallError(t *testing.T) { + client := bufconnResultsClient(t, &fakeVerifierServer{callErr: errors.New("internal")}) + + _, err := client.GetVerifierResultsForMessage(context.Background(), [][]byte{[]byte("id-any")}) + require.Error(t, err) +} diff --git a/tools/configdoc/registry/registry.go b/tools/configdoc/registry/registry.go index 8d038beb5..df45ff87d 100644 --- a/tools/configdoc/registry/registry.go +++ b/tools/configdoc/registry/registry.go @@ -19,6 +19,7 @@ import ( "github.com/smartcontractkit/chainlink-ccv/integration/pkg/accessors/evm" "github.com/smartcontractkit/chainlink-ccv/pkg/chainaccess" "github.com/smartcontractkit/chainlink-ccv/tools/configdoc" + "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/admin" "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/commit" "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/policy" "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/token" @@ -39,6 +40,7 @@ var Targets = []configdoc.Target{ {Name: "bootstrap", Out: "bootstrap/secrets.documented.toml", Kind: configdoc.KindSecrets, New: bootstrapSecretsInstance}, {Name: "monitoring", Out: "common/monitoring.documented.toml", Kind: configdoc.KindConfig, New: monitoringConfigInstance}, {Name: "evm", Out: "evm/config.documented.toml", Kind: configdoc.KindConfig, New: evmConfigInstance}, + {Name: "admin console", Out: "admin-console/config.documented.toml", Kind: configdoc.KindConfig, New: adminConsoleConfigInstance}, } // executorDocInstance builds a fully-populated, valid executor Configuration @@ -191,6 +193,7 @@ func verifierSecretsInstance() any { {SecretName: "aggregator_1", APIKey: "", SecretKey: ""}, }, PolicyHook: &vsecrets.PolicyHookSecret{APIKey: "", SecretKey: ""}, + AdminUI: &vsecrets.AdminUISecret{Username: "operator", Password: ""}, } } @@ -382,3 +385,15 @@ func evmConfig() evm.Config { }, } } + +// adminConsoleConfigInstance builds the documented admin console config. The struct +// has no defaulting routine, so the loopback listen address is set explicitly; the +// optional aggregator override and trace viewer are illustrative. +func adminConsoleConfigInstance() any { + return &admin.Config{ + ListenAddress: admin.DefaultListenAddress, + AggregatorAddress: "aggregator-1:50051", + TraceURL: "https://traces.example.com", + Access: admin.AccessConfig{ActorHeader: "X-Authenticated-User"}, + } +} diff --git a/verifier/migrations/postgres/00010_admin_actions.sql b/verifier/migrations/postgres/00010_admin_actions.sql new file mode 100644 index 000000000..4bc4c999a --- /dev/null +++ b/verifier/migrations/postgres/00010_admin_actions.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- Admin console action log. The console runs in the verifier process and audits its +-- mutations here, in the verifier's own application database. +CREATE TABLE IF NOT EXISTS ccv_admin_actions ( + id BIGSERIAL PRIMARY KEY, + actor TEXT NOT NULL, + action TEXT NOT NULL, + target TEXT NOT NULL DEFAULT '', + operation_id TEXT NOT NULL DEFAULT '', + outcome TEXT NOT NULL, + detail TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS ccv_admin_actions_created_at_idx ON ccv_admin_actions (created_at DESC); + +-- +goose Down +DROP TABLE IF EXISTS ccv_admin_actions; diff --git a/verifier/pkg/admin/actionlog.go b/verifier/pkg/admin/actionlog.go new file mode 100644 index 000000000..282a1d8ef --- /dev/null +++ b/verifier/pkg/admin/actionlog.go @@ -0,0 +1,61 @@ +package admin + +import ( + "context" + "fmt" + "time" + + "github.com/jmoiron/sqlx" +) + +// Action is one console mutation record. OperationID carries the recovery operation ID +// when the action produced one; Detail holds per-target outcomes or error text. +type Action struct { + ID int64 `db:"id"` + Actor string `db:"actor"` + Action string `db:"action"` + Target string `db:"target"` + OperationID string `db:"operation_id"` + Outcome string `db:"outcome"` + Detail string `db:"detail"` + CreatedAt time.Time `db:"created_at"` +} + +// ActionLog is the durable record of every console mutation. It lives in the verifier's +// application database (ccv_admin_actions), alongside the stores the console manages. +type ActionLog struct { + ds *sqlx.DB +} + +func NewActionLog(ds *sqlx.DB) *ActionLog { + return &ActionLog{ds: ds} +} + +func (l *ActionLog) Record(ctx context.Context, a Action) error { + _, err := l.ds.ExecContext(ctx, ` + INSERT INTO ccv_admin_actions (actor, action, target, operation_id, outcome, detail) + VALUES ($1, $2, $3, $4, $5, $6)`, + a.Actor, a.Action, a.Target, a.OperationID, a.Outcome, a.Detail) + if err != nil { + return fmt.Errorf("failed to record action: %w", err) + } + return nil +} + +// List returns newest-first actions; beforeID=0 starts at the latest. +func (l *ActionLog) List(ctx context.Context, limit int, beforeID int64) ([]Action, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + var actions []Action + err := l.ds.SelectContext(ctx, &actions, ` + SELECT id, actor, action, target, operation_id, outcome, detail, created_at + FROM ccv_admin_actions + WHERE ($1 = 0 OR id < $1) + ORDER BY id DESC + LIMIT $2`, beforeID, limit) + if err != nil { + return nil, fmt.Errorf("failed to list actions: %w", err) + } + return actions, nil +} diff --git a/verifier/pkg/admin/actionlog_test.go b/verifier/pkg/admin/actionlog_test.go new file mode 100644 index 000000000..fbf560f84 --- /dev/null +++ b/verifier/pkg/admin/actionlog_test.go @@ -0,0 +1,38 @@ +package admin + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-ccv/verifier/testutil" +) + +// TestActionLogRoundTrip proves the verifier migrations create the console's action +// table (testutil.NewTestDB has already run them) and that Record/List round-trip. +func TestActionLogRoundTrip(t *testing.T) { + db := testutil.NewTestDB(t) + + log := NewActionLog(db) + ctx := context.Background() + require.NoError(t, log.Record(ctx, Action{ + Actor: "alice@example.com", Action: "reschedule", + Target: "0xabc", Outcome: "success", Detail: "job restored to active queue", + })) + require.NoError(t, log.Record(ctx, Action{ + Actor: "alice@example.com", Action: "reschedule", + Target: "0xdef", Outcome: "failed", Detail: "active job may already exist", + })) + + actions, err := log.List(ctx, 100, 0) + require.NoError(t, err) + require.Len(t, actions, 2) + require.Equal(t, "failed", actions[0].Outcome, "newest first") + require.Equal(t, "success", actions[1].Outcome) + + // Pagination: beforeID excludes the boundary row itself. + older, err := log.List(ctx, 100, actions[0].ID) + require.NoError(t, err) + require.Len(t, older, 1) +} diff --git a/verifier/pkg/admin/attestation.go b/verifier/pkg/admin/attestation.go new file mode 100644 index 000000000..ad308aa5f --- /dev/null +++ b/verifier/pkg/admin/attestation.go @@ -0,0 +1,98 @@ +package admin + +import ( + "context" + "fmt" + "time" + + "google.golang.org/grpc/codes" + + "github.com/smartcontractkit/chainlink-ccv/integration/storageaccess" +) + +// AttestationState is the outcome of the per-message freshness check. Unknown is never +// proof that a replay is needed: it disables execution for that target. +type AttestationState string + +const ( + AttestationAttested AttestationState = "attested" + AttestationNotFound AttestationState = "not_found" + AttestationUnknown AttestationState = "unknown" +) + +// AttestationResult is one message's freshness outcome plus operator-facing detail. +type AttestationResult struct { + State AttestationState + Detail string +} + +// attestationCallTimeout bounds every external freshness call so a preview never hangs. +const attestationCallTimeout = 5 * time.Second + +// checkAttestations checks each message against the aggregator's read path. An empty +// address means freshness checks are not configured: every result is Unknown, which +// disables execution rather than proving a replay is needed. +func checkAttestations(ctx context.Context, aggregatorAddress string, messageIDs [][]byte) []AttestationResult { + if aggregatorAddress == "" { + results := make([]AttestationResult, len(messageIDs)) + for i := range results { + results[i] = AttestationResult{AttestationUnknown, "attestation check not configured (no aggregator address)"} + } + return results + } + return checkAggregatorAttestations(ctx, aggregatorAddress, messageIDs) +} + +// dialVerifierClient opens the aggregator's read path for freshness checks. A var so +// tests can substitute a fake; the protobuf dialer lives in storageaccess because +// verifier packages must not import the chainlink protos. +var dialVerifierClient = storageaccess.DialResultsClient + +func checkAggregatorAttestations(ctx context.Context, address string, messageIDs [][]byte) []AttestationResult { + results := make([]AttestationResult, len(messageIDs)) + markUnknown := func(detail string) []AttestationResult { + for i := range results { + results[i] = AttestationResult{AttestationUnknown, detail} + } + return results + } + client, err := dialVerifierClient(address) + if err != nil { + return markUnknown(err.Error()) + } + defer func() { _ = client.Close() }() + + callCtx, cancel := context.WithTimeout(ctx, attestationCallTimeout) + defer cancel() + entries, err := client.GetVerifierResultsForMessage(callCtx, messageIDs) + if err != nil { + return markUnknown("aggregator unreachable: " + err.Error()) + } + for i := range messageIDs { + results[i] = aggregatorEntryResult(entries, i) + } + return results +} + +// aggregatorEntryResult interprets entry i of the batch: only a per-ID NotFound +// proves absence; any other error code leaves the state unknown, so execution +// stays disabled. Only a result with non-empty ccv data proves attestation. +func aggregatorEntryResult(entries []storageaccess.ResultEntry, i int) AttestationResult { + if i >= len(entries) || !entries[i].Present { + return AttestationResult{AttestationUnknown, "aggregator response is missing an entry for this message"} + } + if entries[i].ErrorCode != int32(codes.OK) { + if entries[i].ErrorCode == int32(codes.NotFound) { + return AttestationResult{AttestationNotFound, "aggregator: " + entries[i].ErrorMsg} + } + return AttestationResult{ + AttestationUnknown, + //nolint:gosec // G115: gRPC error codes are always non-negative. + fmt.Sprintf("aggregator error %s: %s", codes.Code(entries[i].ErrorCode), entries[i].ErrorMsg), + } + } + if len(entries[i].CcvData) > 0 { + return AttestationResult{AttestationAttested, "aggregator holds ccv data for this message"} + } + return AttestationResult{AttestationNotFound, "aggregator returned empty ccv data"} +} diff --git a/verifier/pkg/admin/attestation_test.go b/verifier/pkg/admin/attestation_test.go new file mode 100644 index 000000000..060b3c9fd --- /dev/null +++ b/verifier/pkg/admin/attestation_test.go @@ -0,0 +1,136 @@ +package admin + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + + "github.com/smartcontractkit/chainlink-ccv/integration/storageaccess" +) + +// fakeResultsClient serves canned per-index entries: the protobuf translation is +// tested in storageaccess; these tests cover the console's interpretation. +type fakeResultsClient struct { + entries []storageaccess.ResultEntry + callErr error +} + +func (f *fakeResultsClient) GetVerifierResultsForMessage(_ context.Context, _ [][]byte) ([]storageaccess.ResultEntry, error) { + if f.callErr != nil { + return nil, f.callErr + } + return f.entries, nil +} + +func (f *fakeResultsClient) Close() error { return nil } + +// notFoundClient answers "not found" for every requested ID, like an aggregator that +// holds none of the messages. +type notFoundClient struct{} + +func (notFoundClient) GetVerifierResultsForMessage(_ context.Context, messageIDs [][]byte) ([]storageaccess.ResultEntry, error) { + entries := make([]storageaccess.ResultEntry, len(messageIDs)) + for i := range entries { + entries[i] = storageaccess.ResultEntry{Present: true, ErrorCode: int32(codes.NotFound), ErrorMsg: "message ID not found"} + } + return entries, nil +} + +func (notFoundClient) Close() error { return nil } + +func notFoundResultsClient() notFoundClient { return notFoundClient{} } + +// installFakeResultsClient points the aggregator dial seam at a canned client. +func installFakeResultsClient(t *testing.T, client storageaccess.ResultsClient) { + t.Helper() + orig := dialVerifierClient + dialVerifierClient = func(string) (storageaccess.ResultsClient, error) { return client, nil } + t.Cleanup(func() { dialVerifierClient = orig }) +} + +// installDialError points the aggregator dial seam at a failing dial. +func installDialError(t *testing.T, err error) { + t.Helper() + orig := dialVerifierClient + dialVerifierClient = func(string) (storageaccess.ResultsClient, error) { return nil, err } + t.Cleanup(func() { dialVerifierClient = orig }) +} + +func TestAggregatorAttested(t *testing.T) { + installFakeResultsClient(t, &fakeResultsClient{entries: []storageaccess.ResultEntry{ + {Present: true, CcvData: []byte{0xde, 0xad}}, + }}) + + id := rescheduleMsgID(1) + results := checkAttestations(context.Background(), "agg:443", [][]byte{id}) + require.Len(t, results, 1) + require.Equal(t, AttestationAttested, results[0].State) + require.Contains(t, results[0].Detail, "aggregator") +} + +func TestAggregatorPerIDErrorMeansNotFound(t *testing.T) { + installFakeResultsClient(t, &fakeResultsClient{entries: []storageaccess.ResultEntry{ + {Present: true, ErrorCode: int32(codes.NotFound), ErrorMsg: "message ID not found"}, + }}) + + results := checkAttestations(context.Background(), "agg:443", [][]byte{rescheduleMsgID(2)}) + require.Equal(t, AttestationNotFound, results[0].State) + require.Contains(t, results[0].Detail, "message ID not found") +} + +// A per-ID Internal error (a mapping or config failure) is not proof of +// absence: only NotFound is. The state stays unknown, so execution remains +// disabled rather than allowing a replay. +func TestAggregatorPerIDInternalErrorIsUnknown(t *testing.T) { + installFakeResultsClient(t, &fakeResultsClient{entries: []storageaccess.ResultEntry{ + {Present: true, ErrorCode: int32(codes.Internal), ErrorMsg: "dest chain not mapped"}, + }}) + + results := checkAttestations(context.Background(), "agg:443", [][]byte{rescheduleMsgID(7)}) + require.Equal(t, AttestationUnknown, results[0].State) + require.Contains(t, results[0].Detail, "Internal") + require.Contains(t, results[0].Detail, "dest chain not mapped") +} + +func TestAggregatorEmptyCcvDataMeansNotFound(t *testing.T) { + installFakeResultsClient(t, &fakeResultsClient{entries: []storageaccess.ResultEntry{ + {Present: true, CcvData: []byte{}}, + }}) + + results := checkAttestations(context.Background(), "agg:443", [][]byte{rescheduleMsgID(3)}) + require.Equal(t, AttestationNotFound, results[0].State) +} + +func TestAggregatorCallErrorIsUnknown(t *testing.T) { + installFakeResultsClient(t, &fakeResultsClient{callErr: context.DeadlineExceeded}) + + results := checkAttestations(context.Background(), "agg:443", [][]byte{rescheduleMsgID(4)}) + require.Equal(t, AttestationUnknown, results[0].State) + require.Contains(t, results[0].Detail, "aggregator unreachable") +} + +func TestAggregatorDialErrorIsUnknown(t *testing.T) { + installDialError(t, errors.New("connection refused")) + + results := checkAttestations(context.Background(), "agg:443", [][]byte{rescheduleMsgID(4)}) + require.Equal(t, AttestationUnknown, results[0].State) + require.Equal(t, "connection refused", results[0].Detail) +} + +func TestAggregatorMissingEntryIsUnknown(t *testing.T) { + installFakeResultsClient(t, &fakeResultsClient{entries: []storageaccess.ResultEntry{}}) + + results := checkAttestations(context.Background(), "agg:443", [][]byte{rescheduleMsgID(5)}) + require.Equal(t, AttestationUnknown, results[0].State) + require.Contains(t, results[0].Detail, "missing an entry") +} + +func TestAttestationNotConfiguredIsUnknown(t *testing.T) { + results := checkAttestations(context.Background(), "", [][]byte{rescheduleMsgID(9)}) + require.Len(t, results, 1) + require.Equal(t, AttestationUnknown, results[0].State) + require.Contains(t, results[0].Detail, "not configured") +} diff --git a/verifier/pkg/admin/auth.go b/verifier/pkg/admin/auth.go new file mode 100644 index 000000000..9f2e993ad --- /dev/null +++ b/verifier/pkg/admin/auth.go @@ -0,0 +1,47 @@ +package admin + +import ( + "errors" + "net" + + "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/vsecrets" +) + +// BasicAuth is the console UI credential from the verifier secrets file's +// [admin_ui] table. Nil means the console serves without basic auth (the +// loopback personal-tool default). +type BasicAuth struct { + Username string + Password string +} + +// BasicAuthFromSecrets extracts the [admin_ui] pair from the verifier secrets file. A +// half-supplied pair is a startup error, never a silent downgrade to unauthenticated +// serving. +func BasicAuthFromSecrets(s *vsecrets.VerifierSecrets) (*BasicAuth, error) { + if s == nil || s.AdminUIAuth() == nil { + return nil, nil + } + ui := s.AdminUIAuth() + if ui.Username == "" || ui.Password == "" { + return nil, errors.New("console secrets file [admin_ui] requires both username and password (remove the table to serve without basic auth)") + } + return &BasicAuth{Username: ui.Username, Password: ui.Password}, nil +} + +// ValidateAccessPolicy enforces the console's exposure contract: non-loopback +// serving (including a wildcard bind) requires an identity source — the proxy +// actor header or basic auth. +func ValidateAccessPolicy(cfg *Config, auth *BasicAuth) error { + host, _, err := net.SplitHostPort(cfg.ListenAddress) + if err != nil { + return err + } + if host == "127.0.0.1" || host == "::1" || host == "localhost" { + return nil + } + if cfg.Access.ActorHeader == "" && auth == nil { + return errors.New("serving a page grants privileged actions: a non-loopback listen_address requires an identity source — access.actor_header (authenticating proxy) or [admin_ui] basic auth in the verifier secrets file") + } + return nil +} diff --git a/verifier/pkg/admin/auth_test.go b/verifier/pkg/admin/auth_test.go new file mode 100644 index 000000000..2044c6b94 --- /dev/null +++ b/verifier/pkg/admin/auth_test.go @@ -0,0 +1,155 @@ +package admin + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/vsecrets" + "github.com/smartcontractkit/chainlink-common/pkg/logger" +) + +func writeSecrets(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "secrets.toml") + require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) + return path +} + +func TestBasicAuthFromSecrets(t *testing.T) { + t.Run("nil secrets and absent table both mean no auth", func(t *testing.T) { + auth, err := BasicAuthFromSecrets(nil) + require.NoError(t, err) + require.Nil(t, auth) + + secrets, err := vsecrets.Load(writeSecrets(t, `[db] +url = "postgres://demo:demo@localhost/db" +`)) + require.NoError(t, err) + auth, err = BasicAuthFromSecrets(secrets) + require.NoError(t, err) + require.Nil(t, auth) + }) + + t.Run("a full pair enables basic auth", func(t *testing.T) { + secrets, err := vsecrets.Load(writeSecrets(t, `[admin_ui] +username = "operator" +password = "s3cret" +`)) + require.NoError(t, err) + auth, err := BasicAuthFromSecrets(secrets) + require.NoError(t, err) + require.Equal(t, &BasicAuth{Username: "operator", Password: "s3cret"}, auth) + }) + + t.Run("a half-supplied pair is a startup error, not a silent downgrade", func(t *testing.T) { + for _, body := range []string{ + "[admin_ui]\nusername = \"operator\"\n", + "[admin_ui]\npassword = \"s3cret\"\n", + } { + secrets, err := vsecrets.Load(writeSecrets(t, body)) + require.NoError(t, err) + _, err = BasicAuthFromSecrets(secrets) + require.ErrorContains(t, err, "[admin_ui] requires both username and password") + } + }) +} + +func TestValidateAccessPolicy(t *testing.T) { + cfg := func(addr, header string) *Config { + return &Config{ListenAddress: addr, Access: AccessConfig{ActorHeader: header}} + } + auth := &BasicAuth{Username: "u", Password: "p"} + + // Loopback needs nothing; a wildcard bind counts as non-loopback. + for _, addr := range []string{"127.0.0.1:8105", "localhost:8105", "[::1]:8105"} { + require.NoError(t, ValidateAccessPolicy(cfg(addr, ""), nil), addr) + } + for _, addr := range []string{"0.0.0.0:8105", ":8105", "10.0.0.5:8105"} { + require.ErrorContains(t, ValidateAccessPolicy(cfg(addr, ""), nil), "identity source", addr) + require.NoError(t, ValidateAccessPolicy(cfg(addr, "X-Remote-User"), nil), addr) + require.NoError(t, ValidateAccessPolicy(cfg(addr, ""), auth), addr) + } +} + +// newTestServerWithSecrets builds a server with basic auth parsed from a secrets file +// carrying the given content, so the full [admin_ui] gate is exercisable. +func newTestServerWithSecrets(t *testing.T, cfgBody, secretsBody string) *Server { + t.Helper() + secrets, err := vsecrets.Load(writeSecrets(t, secretsBody)) + require.NoError(t, err) + auth, err := BasicAuthFromSecrets(secrets) + require.NoError(t, err) + return newTestServer(t, cfgBody, auth) +} + +func TestServerBasicAuth(t *testing.T) { + srv := newTestServerWithSecrets(t, "", `[admin_ui] +username = "operator" +password = "s3cret" +`) + + t.Run("unauthenticated requests are rejected with a challenge", func(t *testing.T) { + for _, path := range []string{"/", "/search", "/actions"} { + rec := httptest.NewRecorder() + srv.router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + require.Equal(t, http.StatusUnauthorized, rec.Code, path) + require.Equal(t, `Basic realm="ccv-admin"`, rec.Header().Get("WWW-Authenticate")) + } + }) + + t.Run("healthz stays open for probes", func(t *testing.T) { + rec := httptest.NewRecorder() + srv.router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + require.Equal(t, http.StatusOK, rec.Code) + }) + + t.Run("wrong credentials are rejected", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.SetBasicAuth("operator", "wrong") + srv.router.ServeHTTP(rec, req) + require.Equal(t, http.StatusUnauthorized, rec.Code) + }) + + t.Run("the authenticated username becomes the actor", func(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/search", nil) + c.Request.SetBasicAuth("operator", "s3cret") + srv.basicAuthMiddleware(c) + require.False(t, c.IsAborted()) + actor, ok := c.Get("actor") + require.True(t, ok) + require.Equal(t, "operator", actor) + }) +} + +func TestServerBasicAuthStartupRules(t *testing.T) { + t.Run("half-supplied pair fails startup", func(t *testing.T) { + secrets, err := vsecrets.Load(writeSecrets(t, "[admin_ui]\nusername = \"operator\"\n")) + require.NoError(t, err) + _, err = BasicAuthFromSecrets(secrets) + require.ErrorContains(t, err, "[admin_ui]") + }) + + t.Run("basic auth satisfies the non-loopback identity rule", func(t *testing.T) { + cfg, err := LoadConfig(writeConfig(t, `listen_address = "0.0.0.0:8105"`+"\n")) + require.NoError(t, err) + _, err = NewServer(cfg, Deps{DB: newFakeSQLDB(t), Auth: &BasicAuth{Username: "u", Password: "p"}}, logger.Test(t)) + require.NoError(t, err) + }) + + t.Run("non-loopback without any identity source fails startup", func(t *testing.T) { + cfg, err := LoadConfig(writeConfig(t, `listen_address = "0.0.0.0:8105"`+"\n")) + require.NoError(t, err) + _, err = NewServer(cfg, Deps{DB: newFakeSQLDB(t)}, logger.Test(t)) + require.ErrorContains(t, err, "identity source") + }) +} diff --git a/verifier/pkg/admin/config.go b/verifier/pkg/admin/config.go new file mode 100644 index 000000000..a5ebedfb4 --- /dev/null +++ b/verifier/pkg/admin/config.go @@ -0,0 +1,86 @@ +// Package admin implements the CCV admin console: a server-rendered UI over one +// verifier's recovery stores, wrapping the job-queue / recovery CLI semantics so +// operators can find, explain, and recover dropped messages without database access. +package admin + +import ( + "errors" + "fmt" + "io/fs" + "net" + "os" + + "github.com/BurntSushi/toml" +) + +const ( + // DefaultListenAddress binds the console to loopback unless configured otherwise. + DefaultListenAddress = "127.0.0.1:8105" + // ConfigPathEnv overrides the default console config path. + ConfigPathEnv = "CCV_ADMIN_CONFIG_PATH" + // DefaultConfigPath is the console config file's default location. A present file + // enables the console: the verifier factory serves it in-process alongside the job. + DefaultConfigPath = "/etc/ccv-admin/config.toml" +) + +// Config is the console configuration file schema. It carries no credentials and no +// database settings: the console administers the verifier it runs beside, sharing that +// verifier's application database (the action log lives there too) and its secrets file +// (basic auth comes from its [admin_ui] table). +type Config struct { + // ListenAddress is the bind address; loopback by default. + ListenAddress string `toml:"listen_address"` + // AggregatorAddress (optional, host:port) overrides the aggregator used for + // attestation freshness checks via the unauthenticated GetVerifierResultsForMessage. + // Empty uses the verifier's own first configured aggregator. + AggregatorAddress string `toml:"aggregator_address"` + // TraceURL (optional) is a base URL to the operator's trace viewer — typically an + // internal Grafana/Tempo or Jaeger — linked from the message detail page when set. + TraceURL string `toml:"trace_url"` + // Access configures how the console identifies who is acting. + Access AccessConfig `toml:"access"` +} + +type AccessConfig struct { + // ActorHeader names the HTTP header carrying an authenticated identity from a + // fronting proxy (shared hosting). Empty means self-hosted loopback: actor "local". + // Non-loopback serving requires this header or [admin_ui] basic auth from the + // verifier secrets file (validated at startup, when the secrets are loaded). + ActorHeader string `toml:"actor_header"` +} + +// LoadConfig reads and validates the console config. A missing file is an error: the +// factory treats file presence as the enable signal and loads only when it exists. +func LoadConfig(path string) (*Config, error) { + raw, err := os.ReadFile(path) //nolint:gosec // G304: path is operator-provided, trusted. + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("console config %q does not exist", path) + } + return nil, fmt.Errorf("failed to read console config %q: %w", path, err) + } + var cfg Config + md, err := toml.Decode(string(raw), &cfg) + if err != nil { + return nil, fmt.Errorf("failed to decode console config %q: %w", path, err) + } + if undecoded := md.Undecoded(); len(undecoded) > 0 { + return nil, fmt.Errorf("console config %q has unknown keys: %+v", path, undecoded) + } + if cfg.ListenAddress == "" { + cfg.ListenAddress = DefaultListenAddress + } + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("invalid console config %q: %w", path, err) + } + return &cfg, nil +} + +func (c *Config) Validate() error { + if _, _, err := net.SplitHostPort(c.ListenAddress); err != nil { + return fmt.Errorf("listen_address %q is not host:port: %w", c.ListenAddress, err) + } + // The non-loopback identity rule lives in ValidateAccessPolicy (server startup): + // it needs the verifier secrets, which are not loaded here. + return nil +} diff --git a/verifier/pkg/admin/config_test.go b/verifier/pkg/admin/config_test.go new file mode 100644 index 000000000..3a18a698c --- /dev/null +++ b/verifier/pkg/admin/config_test.go @@ -0,0 +1,66 @@ +package admin + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func writeConfig(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) + return path +} + +func TestLoadConfig(t *testing.T) { + t.Run("parses with loopback default", func(t *testing.T) { + cfg, err := LoadConfig(writeConfig(t, "")) + require.NoError(t, err) + require.Equal(t, DefaultListenAddress, cfg.ListenAddress) + }) + + t.Run("missing file is an error", func(t *testing.T) { + _, err := LoadConfig(filepath.Join(t.TempDir(), "nope.toml")) + require.ErrorContains(t, err, "does not exist") + }) + + t.Run("unknown keys are rejected", func(t *testing.T) { + _, err := LoadConfig(writeConfig(t, "bogus_key = 1\n")) + require.ErrorContains(t, err, "unknown keys") + }) + + t.Run("bad listen address is rejected", func(t *testing.T) { + _, err := LoadConfig(writeConfig(t, `listen_address = "no-port"`+"\n")) + require.ErrorContains(t, err, "listen_address") + }) + + t.Run("optional fields parse", func(t *testing.T) { + cfg, err := LoadConfig(writeConfig(t, ` +aggregator_address = "aggregator-1:50051" +trace_url = "https://traces.example.com" +`)) + require.NoError(t, err) + require.Equal(t, "aggregator-1:50051", cfg.AggregatorAddress) + require.Equal(t, "https://traces.example.com", cfg.TraceURL) + }) + + t.Run("non-loopback listen defers the identity check to startup", func(t *testing.T) { + // The rule needs the verifier secrets (basic auth), so LoadConfig accepts + // the file and ValidateAccessPolicy enforces it at server startup. + cfg, err := LoadConfig(writeConfig(t, `listen_address = "0.0.0.0:8105"`+"\n")) + require.NoError(t, err) + require.ErrorContains(t, ValidateAccessPolicy(cfg, nil), "identity source") + require.NoError(t, ValidateAccessPolicy(cfg, &BasicAuth{Username: "u", Password: "p"})) + + cfg, err = LoadConfig(writeConfig(t, `listen_address = "0.0.0.0:8105" +[access] +actor_header = "X-Remote-User" +`)) + require.NoError(t, err) + require.Equal(t, "X-Remote-User", cfg.Access.ActorHeader) + require.NoError(t, ValidateAccessPolicy(cfg, nil)) + }) +} diff --git a/verifier/pkg/admin/detail.go b/verifier/pkg/admin/detail.go new file mode 100644 index 000000000..89082d950 --- /dev/null +++ b/verifier/pkg/admin/detail.go @@ -0,0 +1,178 @@ +package admin + +import ( + "context" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/smartcontractkit/chainlink-ccv/cli/jobqueue" + recoverycli "github.com/smartcontractkit/chainlink-ccv/cli/recovery" + "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/admin/views" + "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/chainstatus" + recoverystore "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/recovery" +) + +// Detail: per-message page — failure stage/reason, queue, owner, archive age/expiry, +// attempts, trace link, and the durable drop/incident evidence (R4), distinguishing an +// absent archive row from observed pre-admission drops. +func (h *handlers) registerDetailRoutes(r *gin.Engine) { + r.GET("/messages/:messageID", h.detailPage) +} + +// detailChainLister is the chain-status read surface the detail page needs; the +// postgres store satisfies it and tests fake it. +type detailChainLister interface { + List(ctx context.Context) ([]chainstatus.Row, error) +} + +// detailSources bundles the stores behind the detail page. A nil store with its error +// set renders that section as unavailable — never as an empty result. +type detailSources struct { + jq jobqueue.Store + jqErr error + rec recoverycli.Store + recErr error + chain detailChainLister + chainErr error +} + +func (h *handlers) detailPage(c *gin.Context) { + ids, err := jobqueue.ParseMessageIDs([]string{c.Param("messageID")}) + if err != nil { + h.render(c, http.StatusBadRequest, views.ErrorPage("Message detail", err.Error())) + return + } + h.renderDetail(c, detailSources{ + jq: h.stores.JobQueue(), + rec: h.stores.Recovery(), + chain: h.stores.ChainStatuses(), + }, ids[0]) +} + +// renderDetail runs the lookups against the given stores and renders the page. It is +// split from detailPage so tests can drive it with fake stores and no database. +func (h *handlers) renderDetail(c *gin.Context, src detailSources, msgID []byte) { + vm := views.DetailVM{ + MessageID: formatMessageID(msgID), + TraceURL: h.cfg.TraceURL, + } + if src.jq == nil { + vm.UnreachableDetail = detailErrText(src.jqErr, "verifier database unavailable") + h.render(c, http.StatusOK, views.DetailPage(h.csrfToken(c), vm)) + return + } + // verifier/pkg/jobqueue has no active-queue listing by message ID, so queued/processing + // rows can't be shown; a conflicting active job fails the restore safely at reschedule time. + ctx := c.Request.Context() + if jobs, err := src.jq.ListFailedFiltered(ctx, nil, "", [][]byte{msgID}, 0); err != nil { + vm.ArchiveDetail = err.Error() + } else { + for _, j := range jobs { + vm.Failed = append(vm.Failed, toArchivedJobVM(j)) + } + } + h.addDetailEvents(ctx, &vm, src) + h.addDetailChainStatus(ctx, &vm, src) + h.render(c, http.StatusOK, views.DetailPage(h.csrfToken(c), vm)) +} + +func (h *handlers) addDetailEvents(ctx context.Context, vm *views.DetailVM, src detailSources) { + if src.rec == nil { + vm.EventsDetail = detailErrText(src.recErr, "recovery store unavailable") + return + } + page, err := src.rec.ListEvents(ctx, recoverystore.EventFilter{MessageIDs: []string{vm.MessageID}, Limit: 100}) + if err != nil { + vm.EventsDetail = err.Error() + return + } + for _, e := range page.Events { + vm.Events = append(vm.Events, toDropEventVM(e)) + } + vm.RetainedSince = page.RetainedSince + vm.Coverage = page.Coverage +} + +// addDetailChainStatus derives the message's source chain from its archive rows or +// events, then shows the chain-status rows for that chain. +func (h *handlers) addDetailChainStatus(ctx context.Context, vm *views.DetailVM, src detailSources) { + switch { + case len(vm.Failed) > 0: + vm.SourceChain = strconv.FormatUint(vm.Failed[0].Job.ChainSelector, 10) + case len(vm.Events) > 0: + vm.SourceChain = vm.Events[0].SourceChain + } + if vm.SourceChain == "" { + return + } + if src.chain == nil { + vm.ChainDetail = detailErrText(src.chainErr, "chain status store unavailable") + return + } + rows, err := src.chain.List(ctx) + if err != nil { + vm.ChainDetail = err.Error() + return + } + for _, r := range rows { + if strconv.FormatUint(uint64(r.ChainSelector), 10) == vm.SourceChain { + vm.Chains = append(vm.Chains, toChainStatusVM(r)) + } + } +} + +// toArchivedJobVM maps one archive row and builds the reschedule-preview target +// contract: jobID|messageIDHex|queue|ownerID. +func toArchivedJobVM(j jobqueue.ArchivedJob) views.ArchivedJobVM { + label := "Ask the policy endpoint again (re-verify)" + if j.Queue == jobqueue.QueueTypeStorageWriter { + label = "Retry delivering the saved result" + } + return views.ArchivedJobVM{ + Job: j, + ButtonLabel: label, + RescheduleTarget: strings.Join( + []string{j.JobID, formatMessageID(j.MessageID), string(j.Queue), j.OwnerID}, "|"), + } +} + +func toDropEventVM(e recoverystore.Event) views.DropEventVM { + return views.DropEventVM{ + Kind: e.Kind, Stage: e.Stage, Reason: e.Reason, OwnerID: e.OwnerID, + SourceChain: e.SourceChain, SourceBlock: detailDeref(e.SourceBlock), + TxHash: detailDeref(e.TxHash), IncidentID: detailDeref(e.IncidentID), + Observations: e.Observations, + FirstObserved: e.FirstObservedAt, LastObserved: e.LastObservedAt, ExpiresAt: e.ExpiresAt, + } +} + +func toChainStatusVM(r chainstatus.Row) views.ChainStatusVM { + height := "—" + if r.FinalizedBlockHeight != nil { + height = r.FinalizedBlockHeight.String() + } + return views.ChainStatusVM{ + ChainSelector: strconv.FormatUint(uint64(r.ChainSelector), 10), + VerifierID: r.VerifierID, + FinalizedHeight: height, + Disabled: r.Disabled, + UpdatedAt: r.UpdatedAt, + } +} + +func detailErrText(err error, fallback string) string { + if err != nil { + return err.Error() + } + return fallback +} + +func detailDeref(s *string) string { + if s == nil { + return "—" + } + return *s +} diff --git a/verifier/pkg/admin/detail_test.go b/verifier/pkg/admin/detail_test.go new file mode 100644 index 000000000..55ffcf1e8 --- /dev/null +++ b/verifier/pkg/admin/detail_test.go @@ -0,0 +1,257 @@ +package admin + +import ( + "context" + "errors" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-ccv/cli/jobqueue" + "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/chainstatus" + recoverystore "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/recovery" + "github.com/smartcontractkit/chainlink-common/pkg/logger" +) + +type fakeJobQueueStore struct { + jobs []jobqueue.ArchivedJob + err error +} + +func (f *fakeJobQueueStore) ListFailed(context.Context, []jobqueue.QueueType, string, int) ([]jobqueue.ArchivedJob, error) { + return f.jobs, f.err +} + +func (f *fakeJobQueueStore) ListFailedFiltered(context.Context, []jobqueue.QueueType, string, [][]byte, int) ([]jobqueue.ArchivedJob, error) { + return f.jobs, f.err +} + +func (f *fakeJobQueueStore) Reschedule(context.Context, jobqueue.QueueType, string, string, []byte, time.Duration) (jobqueue.ArchivedJob, error) { + return jobqueue.ArchivedJob{}, errors.New("not implemented") +} + +func (f *fakeJobQueueStore) RescheduleByJobID(context.Context, jobqueue.QueueType, string, string, time.Duration) error { + return errors.New("not implemented") +} + +func (f *fakeJobQueueStore) RescheduleByMessageID(context.Context, jobqueue.QueueType, string, []byte, time.Duration) error { + return errors.New("not implemented") +} + +type fakeRecoveryStore struct { + page recoverystore.EventPage + err error +} + +func (f *fakeRecoveryStore) Submit(context.Context, recoverystore.SubmitRequest) (recoverystore.Operation, error) { + return recoverystore.Operation{}, errors.New("not implemented") +} + +func (f *fakeRecoveryStore) Get(context.Context, string) (recoverystore.Operation, error) { + return recoverystore.Operation{}, errors.New("not implemented") +} + +func (f *fakeRecoveryStore) List(context.Context, string, string, int) ([]recoverystore.Operation, error) { + return nil, errors.New("not implemented") +} + +func (f *fakeRecoveryStore) ChangeState(context.Context, string, string) (recoverystore.Operation, error) { + return recoverystore.Operation{}, errors.New("not implemented") +} + +func (f *fakeRecoveryStore) ListEvents(context.Context, recoverystore.EventFilter) (recoverystore.EventPage, error) { + return f.page, f.err +} + +type fakeChainStatusLister struct { + rows []chainstatus.Row + err error +} + +func (f *fakeChainStatusLister) List(context.Context) ([]chainstatus.Row, error) { + return f.rows, f.err +} + +func detailTestMessageID(t *testing.T) []byte { + t.Helper() + id, err := jobqueue.ParseMessageID(strings.Repeat("ab", 32)) + require.NoError(t, err) + return id +} + +// serveDetail renders the page through renderDetail with fake stores; no database is +// touched. +func serveDetail(t *testing.T, src detailSources, msgID []byte) *httptest.ResponseRecorder { + t.Helper() + gin.SetMode(gin.TestMode) + h := &handlers{cfg: &Config{TraceURL: "https://traces.example.com"}, lggr: logger.Test(t)} + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/messages/"+formatMessageID(msgID), nil) + h.renderDetail(c, src, msgID) + return rec +} + +func TestDetailPreAdmissionDrop(t *testing.T) { + msgID := detailTestMessageID(t) + since := time.Now().Add(-30 * 24 * time.Hour).UTC().Truncate(time.Second) + src := detailSources{ + jq: &fakeJobQueueStore{}, + rec: &fakeRecoveryStore{page: recoverystore.EventPage{ + Events: []recoverystore.Event{{ + OwnerID: "verifier-1a", SourceChain: "1", Kind: "drop", Stage: "pre_admission", + Reason: "remote_chain_cursed", SourceBlock: new("12345"), TxHash: new("0xdeadbeef"), + FirstObservedAt: since, LastObservedAt: since.Add(time.Hour), + Observations: "2", ExpiresAt: since.Add(30 * 24 * time.Hour), + }}, + RetainedSince: since, + Coverage: "Observed events only. Empty results do not prove no affected traffic.", + }}, + chain: &fakeChainStatusLister{rows: []chainstatus.Row{{ + ChainSelector: 1, VerifierID: "verifier-1a", FinalizedBlockHeight: big.NewInt(12340), + }}}, + } + rec := serveDetail(t, src, msgID) + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + require.Contains(t, body, "dropped before queue admission") + require.Contains(t, body, "nothing to reschedule") + require.Contains(t, body, "/recovery") + require.Contains(t, body, "remote_chain_cursed") + require.Contains(t, body, "0xdeadbeef") + require.NotContains(t, body, `name="target"`) + require.Contains(t, body, "12340") // finalized height from the chain-status row + require.Contains(t, body, "https://traces.example.com") +} + +func TestDetailNotFound(t *testing.T) { + msgID := detailTestMessageID(t) + since := time.Now().Add(-30 * 24 * time.Hour).UTC().Truncate(time.Second) + src := detailSources{ + jq: &fakeJobQueueStore{}, + rec: &fakeRecoveryStore{page: recoverystore.EventPage{ + RetainedSince: since, + Coverage: "Observed events only. Empty results do not prove no affected traffic.", + }}, + chain: &fakeChainStatusLister{}, + } + rec := serveDetail(t, src, msgID) + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + require.Contains(t, body, "Message not found") + require.Contains(t, body, "No archived failed jobs") + require.Contains(t, body, "No drop or incident events") + require.Contains(t, body, "Empty results do not prove no affected traffic") + require.Contains(t, body, "Event history retained since "+since.Format(time.RFC3339)) + require.Contains(t, body, "Source chain unknown") +} + +func TestDetailArchivedRows(t *testing.T) { + msgID := detailTestMessageID(t) + created := time.Now().Add(-48 * time.Hour).UTC().Truncate(time.Second) + archived := time.Now().Add(-26 * time.Hour).UTC().Truncate(time.Second) + deadline := created.Add(time.Hour) + src := detailSources{ + jq: &fakeJobQueueStore{jobs: []jobqueue.ArchivedJob{ + { + JobID: "job-1111", MessageID: msgID, OwnerID: "verifier-1a", ChainSelector: 1, + AttemptCount: 7, LastError: "policy hook rejected: FAIL", FailureCategory: "policy_rejected", + CreatedAt: created, ArchivedAt: &archived, RetryDeadline: deadline, + Queue: jobqueue.QueueTypeTaskVerifier, + }, + { + JobID: "job-2222", MessageID: msgID, OwnerID: "verifier-1a", ChainSelector: 1, + AttemptCount: 3, LastError: "connection refused", FailureCategory: "storage_failure", + CreatedAt: created, ArchivedAt: &archived, RetryDeadline: deadline, + Queue: jobqueue.QueueTypeStorageWriter, + }, + }}, + rec: &fakeRecoveryStore{page: recoverystore.EventPage{ + RetainedSince: time.Now().Add(-30 * 24 * time.Hour).UTC(), Coverage: "coverage-note", + }}, + chain: &fakeChainStatusLister{rows: []chainstatus.Row{{ + ChainSelector: 1, VerifierID: "verifier-1a", FinalizedBlockHeight: big.NewInt(99), + }}}, + } + rec := serveDetail(t, src, msgID) + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + require.Contains(t, body, "2 archived failed job(s)") + require.Contains(t, body, "policy_rejected") + require.Contains(t, body, "storage_failure") + require.Contains(t, body, "policy hook rejected: FAIL") + require.Contains(t, body, "connection refused") + require.Contains(t, body, archived.Add(30*24*time.Hour).Format(time.RFC3339)) // archive expiry + require.Contains(t, body, archived.Format(time.RFC3339)) + require.Contains(t, body, deadline.Format(time.RFC3339)) + require.Contains(t, body, "26h") // archive age + require.Contains(t, body, "Ask the policy endpoint again (re-verify)") + require.Contains(t, body, "Retry delivering the saved result") + require.Contains(t, body, "neither action re-checks") + require.Contains(t, body, `action="/reschedule/preview"`) + require.NotContains(t, body, "dropped before queue admission") +} + +func TestDetailRescheduleTargetContract(t *testing.T) { + msgID := detailTestMessageID(t) + archived := time.Now().UTC().Truncate(time.Second) + src := detailSources{ + jq: &fakeJobQueueStore{jobs: []jobqueue.ArchivedJob{{ + JobID: "job-abc", MessageID: msgID, OwnerID: "verifier-1a", ChainSelector: 1, + ArchivedAt: &archived, Queue: jobqueue.QueueTypeTaskVerifier, + }}}, + rec: &fakeRecoveryStore{page: recoverystore.EventPage{RetainedSince: time.Now().UTC()}}, + chain: &fakeChainStatusLister{}, + } + rec := serveDetail(t, src, msgID) + require.Equal(t, http.StatusOK, rec.Code) + want := "job-abc|" + formatMessageID(msgID) + "|task-verifier|verifier-1a" + require.Contains(t, rec.Body.String(), `name="target" value="`+want+`"`) +} + +func TestDetailDatabaseUnavailable(t *testing.T) { + msgID := detailTestMessageID(t) + rec := serveDetail(t, detailSources{jqErr: errors.New("connection refused")}, msgID) + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + require.Contains(t, body, "Database unavailable") + require.Contains(t, body, "unknown, not absent") +} + +func TestDetailInvalidMessageID(t *testing.T) { + h := &handlers{cfg: &Config{}, lggr: logger.Test(t)} + gin.SetMode(gin.TestMode) + r := gin.New() + h.registerDetailRoutes(r) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/messages/0xzz", nil) + r.ServeHTTP(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestDetailDisabledReader(t *testing.T) { + msgID := detailTestMessageID(t) + archived := time.Now().UTC().Truncate(time.Second) + src := detailSources{ + jq: &fakeJobQueueStore{jobs: []jobqueue.ArchivedJob{{ + JobID: "job-1", MessageID: msgID, OwnerID: "verifier-1a", ChainSelector: 1, + ArchivedAt: &archived, Queue: jobqueue.QueueTypeTaskVerifier, + }}}, + rec: &fakeRecoveryStore{page: recoverystore.EventPage{RetainedSince: time.Now().UTC()}}, + chain: &fakeChainStatusLister{rows: []chainstatus.Row{{ + ChainSelector: 1, VerifierID: "verifier-1a", FinalizedBlockHeight: big.NewInt(42), Disabled: true, + }}}, + } + rec := serveDetail(t, src, msgID) + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + require.Contains(t, body, "disabled") + require.Contains(t, body, "investigated reset-reader") + require.Contains(t, body, "/recovery") +} diff --git a/verifier/pkg/admin/doccomments_gen.go b/verifier/pkg/admin/doccomments_gen.go new file mode 100644 index 000000000..059222d64 --- /dev/null +++ b/verifier/pkg/admin/doccomments_gen.go @@ -0,0 +1,20 @@ +// Code generated by github.com/smartcontractkit/chainlink-ccv/tools/configdoc, DO NOT EDIT. + +package admin + +import "github.com/smartcontractkit/chainlink-common/x/config/commentparsing" + +func (AccessConfig) DocComments() map[string]commentparsing.FieldDoc { + return map[string]commentparsing.FieldDoc{ + "ActorHeader": {Comment: "ActorHeader names the HTTP header carrying an authenticated identity from a\nfronting proxy (shared hosting). Empty means self-hosted loopback: actor \"local\".\nNon-loopback serving requires this header or [admin_ui] basic auth from the\nverifier secrets file (validated at startup, when the secrets are loaded)."}, + } +} + +func (Config) DocComments() map[string]commentparsing.FieldDoc { + return map[string]commentparsing.FieldDoc{ + "Access": {Comment: "Access configures how the console identifies who is acting."}, + "AggregatorAddress": {Comment: "AggregatorAddress (optional, host:port) overrides the aggregator used for\nattestation freshness checks via the unauthenticated GetVerifierResultsForMessage.\nEmpty uses the verifier's own first configured aggregator."}, + "ListenAddress": {Comment: "ListenAddress is the bind address; loopback by default."}, + "TraceURL": {Comment: "TraceURL (optional) is a base URL to the operator's trace viewer — typically an\ninternal Grafana/Tempo or Jaeger — linked from the message detail page when set."}, + } +} diff --git a/verifier/pkg/admin/handlers.go b/verifier/pkg/admin/handlers.go new file mode 100644 index 000000000..8d161c49f --- /dev/null +++ b/verifier/pkg/admin/handlers.go @@ -0,0 +1,79 @@ +package admin + +import ( + "net/http" + "strconv" + + "github.com/a-h/templ" + "github.com/gin-gonic/gin" + + "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/admin/views" + "github.com/smartcontractkit/chainlink-common/pkg/logger" +) + +// handlers holds the shared dependencies every route group uses. Route registration is +// split per feature (search.go, detail.go, reschedule.go, recoveryops.go); +// this file carries the struct, the helpers, and the core pages. +type handlers struct { + cfg *Config + lggr logger.Logger + stores stores + actions *ActionLog + aggregatorAddress string +} + +func (h *handlers) actor(c *gin.Context) string { + if v, ok := c.Get("actor"); ok { + if s, ok := v.(string); ok { + return s + } + } + return "local" +} + +func (h *handlers) csrfToken(c *gin.Context) string { + if v, ok := c.Get("csrfToken"); ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +func (h *handlers) render(c *gin.Context, status int, component templ.Component) { + c.Header("Content-Type", "text/html; charset=utf-8") + c.Status(status) + if err := component.Render(c.Request.Context(), c.Writer); err != nil { + h.lggr.Errorw("failed to render page", "error", err) + } +} + +// recordAction writes one action-log entry. Logging failure fails the mutation: an +// unaudited privileged action must not proceed silently. +func (h *handlers) recordAction(c *gin.Context, a Action) error { + a.Actor = h.actor(c) + return h.actions.Record(c.Request.Context(), a) +} + +func (h *handlers) registerCoreRoutes(r *gin.Engine) { + r.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) + r.GET("/", func(c *gin.Context) { c.Redirect(http.StatusFound, "/search") }) + r.GET("/actions", h.actionsPage) +} + +func (h *handlers) actionsPage(c *gin.Context) { + before, _ := strconv.ParseInt(c.Query("before"), 10, 64) + actions, err := h.actions.List(c.Request.Context(), 100, before) + if err != nil { + h.render(c, http.StatusInternalServerError, views.ErrorPage("Action log", err.Error())) + return + } + vms := make([]views.ActionVM, 0, len(actions)) + for _, a := range actions { + vms = append(vms, views.ActionVM{ + Actor: a.Actor, Action: a.Action, Target: a.Target, + OperationID: a.OperationID, Outcome: a.Outcome, Detail: a.Detail, CreatedAt: a.CreatedAt, + }) + } + h.render(c, http.StatusOK, views.ActionsPage(vms)) +} diff --git a/verifier/pkg/admin/recoveryops.go b/verifier/pkg/admin/recoveryops.go new file mode 100644 index 000000000..47b61de33 --- /dev/null +++ b/verifier/pkg/admin/recoveryops.go @@ -0,0 +1,558 @@ +package admin + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + recoverycli "github.com/smartcontractkit/chainlink-ccv/cli/recovery" + "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/admin/views" + "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/chainstatus" + "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/recovery" +) + +// Recovery (U2): live source-range replay and the investigated reader reset, with +// durable operations (progress/cancel/resume across reloads) and R4 evidence display. +// Ordinary replay must not enable a finality-blocked reader; that needs reset-reader. + +// Test seams: swapped by recoveryops_test.go; production wiring goes to the verifier DB. +var recoveryStoreOf = func(s stores) recoverycli.Store { return s.Recovery() } + +type chainStatusLister interface { + List(context.Context) ([]chainstatus.Row, error) +} + +var chainStatusesOf = func(s stores) chainStatusLister { return s.ChainStatuses() } + +func (h *handlers) registerRecoveryRoutes(r *gin.Engine) { + r.GET("/recovery", h.recoveryPage) + r.GET("/recovery/evidence", h.recoveryEvidence) + r.POST("/recovery/preview", h.recoveryPreview) + r.POST("/recovery/submit", h.recoverySubmit) + r.GET("/recovery/operations", h.recoveryOperations) + r.POST("/recovery/operations/:id/cancel", h.recoveryCancel) + r.POST("/recovery/operations/:id/resume", h.recoveryResume) +} + +func (h *handlers) recoveryPage(c *gin.Context) { + h.render(c, http.StatusOK, views.RecoveryPage(h.csrfToken(c))) +} + +// recoveryFormInput is the validated recovery form. A nil ToBlock means the reader's +// advertised head is captured at submission time. +type recoveryFormInput struct { + owner string + chain string + from uint64 + to *uint64 + mode string + note string + requestID string +} + +// parseRecoveryForm validates the shared form; full=true also requires note/request ID +// for an actual submission. Block bounds mirror the store's own validation. +func parseRecoveryForm(c *gin.Context, full bool) (recoveryFormInput, error) { + var in recoveryFormInput + in.owner = strings.TrimSpace(c.PostForm("owner")) + if in.owner == "" { + return in, errors.New("verifier owner is required") + } + chain, err := strconv.ParseUint(strings.TrimSpace(c.PostForm("chain")), 10, 64) + if err != nil { + return in, fmt.Errorf("source chain must be an unsigned decimal chain selector: %w", err) + } + in.chain = strconv.FormatUint(chain, 10) + from, err := strconv.ParseUint(strings.TrimSpace(c.PostForm("from_block")), 10, 64) + if err != nil { + return in, fmt.Errorf("from-block is required and must be an unsigned decimal block number: %w", err) + } + if from == math.MaxUint64 { + return in, fmt.Errorf("from-block must be below %d", uint64(math.MaxUint64)) + } + in.from = from + if raw := strings.TrimSpace(c.PostForm("to_block")); raw != "" { + to, err := strconv.ParseUint(raw, 10, 64) + if err != nil { + return in, fmt.Errorf("to-block must be an unsigned decimal block number: %w", err) + } + if to == math.MaxUint64 { + return in, fmt.Errorf("to-block must be below %d", uint64(math.MaxUint64)) + } + in.to = &to + } + if in.to != nil && in.from > *in.to { + return in, fmt.Errorf("from-block (%d) must not be after to-block (%d)", in.from, *in.to) + } + in.mode = c.PostForm("mode") + if in.mode != "replay" && in.mode != "reset-reader" { + return in, fmt.Errorf("mode must be replay or reset-reader, got %q", in.mode) + } + if !full { + return in, nil + } + in.note = strings.TrimSpace(c.PostForm("note")) + if in.note == "" { + return in, errors.New("a recovery note is required — record the reason and the investigated boundary evidence") + } + in.requestID = strings.TrimSpace(c.PostForm("request_id")) + if in.requestID == "" { + in.requestID = uuid.NewString() + } else if parsed, err := uuid.Parse(in.requestID); err != nil { + return in, fmt.Errorf("request ID must be a UUID: %w", err) + } else { + in.requestID = parsed.String() + } + return in, nil +} + +// recoveryReaderInfo mirrors the per-reader JSON the store embeds in EventPage.Readers. +type recoveryReaderInfo struct { + NodeID string `json:"node_id"` + LatestBlock *string `json:"latest_block"` + HeadObservedAt *time.Time `json:"head_observed_at"` + LastSeenAt *time.Time `json:"last_seen_at"` + HistoryStartedAt *time.Time `json:"history_started_at"` + Disabled bool `json:"disabled"` + ActiveResetID *string `json:"active_reset_id"` + AuditFailures string `json:"audit_failures"` +} + +// recoveryCapability is this verifier's capability for the selected owner/chain. +type recoveryCapability struct { + registered bool + disabled bool + statusLookupFailed bool + latestHead *uint64 + headStale bool + reader *recoveryReaderInfo + finalizedHeight *uint64 +} + +// recoveryCapabilityOf combines the recovery reader registry (head, reset state) with +// chain statuses (authoritative finality disablement, finalized height). +func (h *handlers) recoveryCapabilityOf(ctx context.Context, store recoverycli.Store, owner, chain string) (recoveryCapability, error) { + var capability recoveryCapability + page, err := store.ListEvents(ctx, recovery.EventFilter{OwnerID: owner, SourceChain: chain, Limit: 1}) + if err != nil { + return capability, fmt.Errorf("reader state query failed: %w", err) + } + var readers []recoveryReaderInfo + if len(page.Readers) > 0 { + if err := json.Unmarshal(page.Readers, &readers); err != nil { + return capability, fmt.Errorf("reader metadata unreadable: %w", err) + } + } + if len(readers) > 0 { + capability.registered = true + capability.reader = &readers[0] + capability.disabled = readers[0].Disabled + if readers[0].LatestBlock != nil { + if v, perr := strconv.ParseUint(*readers[0].LatestBlock, 10, 64); perr == nil { + capability.latestHead = &v + } + } + capability.headStale = readers[0].HeadObservedAt == nil || time.Since(*readers[0].HeadObservedAt) > time.Minute + } + lister := chainStatusesOf(h.stores) + if lister == nil { + capability.statusLookupFailed = true + return capability, nil + } + rows, err := lister.List(ctx) + if err != nil { + capability.statusLookupFailed = true + return capability, nil + } + chainNum, _ := strconv.ParseUint(chain, 10, 64) + for _, row := range rows { + if row.VerifierID == owner && uint64(row.ChainSelector) == chainNum { + capability.disabled = capability.disabled || row.Disabled + if row.FinalizedBlockHeight != nil && row.FinalizedBlockHeight.IsUint64() { + v := row.FinalizedBlockHeight.Uint64() + capability.finalizedHeight = &v + } + } + } + return capability, nil +} + +// recoveryModeAllowed enforces the replay/reset split: replay never runs against a +// finality-blocked reader, and reset-reader exists only for one. +func recoveryModeAllowed(mode string, capability recoveryCapability) (bool, string) { + if !capability.registered { + return false, "No reader is registered for this owner/chain; the verifier would reject the submission." + } + switch mode { + case "replay": + if capability.disabled { + return false, "The reader is disabled (finality-blocked): ordinary replay will not run. Investigate the finality incident and use reset-reader instead." + } + if capability.statusLookupFailed { + return false, "Chain-status lookup failed, so finality disablement cannot be ruled out; replay is refused on the safe side. Retry, or investigate the verifier's database." + } + return true, "" + case "reset-reader": + if !capability.disabled { + return false, "The reader is not finality-blocked; reset-reader is the investigated action for a disabled reader. Use replay for an ordinary range re-verification." + } + return true, "" + } + return false, "unknown mode" +} + +func (h *handlers) recoveryPreview(c *gin.Context) { + in, err := parseRecoveryForm(c, false) + if err != nil { + h.render(c, http.StatusBadRequest, views.RecoveryPreviewError(err.Error())) + return + } + vm := views.RecoveryPreviewVM{Mode: in.mode, SubmitEnabled: true} + vm.Finding = h.recoveryPreviewNode(c.Request.Context(), in) + if !vm.Finding.Allowed { + vm.SubmitEnabled = false + } + h.render(c, http.StatusOK, views.RecoveryPreview(vm)) +} + +func (h *handlers) recoveryPreviewNode(ctx context.Context, in recoveryFormInput) views.RecoveryPreviewNodeVM { + vm := views.RecoveryPreviewNodeVM{LatestHead: "unknown", FinalizedHeight: "unknown"} + store := recoveryStoreOf(h.stores) + capability, err := h.recoveryCapabilityOf(ctx, store, in.owner, in.chain) + if err != nil { + vm.Error = err.Error() + return vm + } + vm.Registered = capability.registered + vm.ReaderDisabled = capability.disabled + if capability.latestHead != nil { + vm.LatestHead = strconv.FormatUint(*capability.latestHead, 10) + } + vm.HeadStale = capability.registered && capability.headStale + if capability.finalizedHeight != nil { + vm.FinalizedHeight = strconv.FormatUint(*capability.finalizedHeight, 10) + } + if capability.reader != nil && capability.reader.ActiveResetID != nil { + vm.ActiveResetID = *capability.reader.ActiveResetID + } + vm.RangeText = recoveryRangeText(in) + vm.Warnings = recoveryWarnings(in, capability) + vm.Allowed, vm.BlockedReason = recoveryModeAllowed(in.mode, capability) + return vm +} + +func recoveryRangeText(in recoveryFormInput) string { + if in.to == nil { + return fmt.Sprintf("From block %d; to-block omitted: the reader's advertised head is captured at submission (it must be under a minute old) and never follows the chain afterwards.", in.from) + } + size := *in.to - in.from + 1 + chunks := (size + recovery.MaxChunkBlocks - 1) / recovery.MaxChunkBlocks + return fmt.Sprintf("Blocks %d–%d: %d block(s), processed as %d chunk(s) of at most %d blocks / %d events.", + in.from, *in.to, size, chunks, recovery.MaxChunkBlocks, recovery.MaxChunkMessages) +} + +func recoveryWarnings(in recoveryFormInput, capability recoveryCapability) []string { + var warnings []string + if capability.finalizedHeight != nil && in.from < *capability.finalizedHeight { + warnings = append(warnings, fmt.Sprintf( + "From-block %d is below the current finalized height %d: this range may revisit already-attested traffic, and it covers every lane on this source chain, not one message.", + in.from, *capability.finalizedHeight)) + } + if in.to == nil && capability.registered && capability.headStale { + warnings = append(warnings, "The reader's last advertised head is stale or missing, so an omitted to-block will be rejected; set an explicit to-block.") + } + if capability.reader != nil && capability.reader.ActiveResetID != nil { + warnings = append(warnings, "An applied reset ("+*capability.reader.ActiveResetID+") owns normal polling until it completes; a new investigated reset marks it superseded.") + } + if capability.statusLookupFailed { + warnings = append(warnings, "Chain-status lookup failed; finalized height and the authoritative disabled flag are unavailable.") + } + if capability.reader != nil && capability.reader.AuditFailures != "" && capability.reader.AuditFailures != "0" { + warnings = append(warnings, "This reader reports "+capability.reader.AuditFailures+" failed evidence writes; retained history below may have gaps.") + } + return warnings +} + +func (h *handlers) recoverySubmit(c *gin.Context) { + in, err := parseRecoveryForm(c, true) + if err != nil { + h.render(c, http.StatusBadRequest, views.RecoverySubmitError(err.Error())) + return + } + res := h.recoverySubmitOne(c, in) + h.render(c, http.StatusOK, views.RecoverySubmitResult(res, in.requestID)) +} + +// recoverySubmitOne submits to this verifier's recovery store. An intent row precedes +// the submission (an unloggable mutation does not proceed) and an outcome row follows. +func (h *handlers) recoverySubmitOne(c *gin.Context, in recoveryFormInput) views.RecoverySubmitVM { + res := views.RecoverySubmitVM{} + target := recoveryTarget(in) + fail := func(detail string) { + res.Error = detail + if err := h.recordAction(c, Action{Action: "recovery-submit", Target: target, Outcome: "failed", Detail: detail}); err != nil { + res.Error += " (action log write failed: " + err.Error() + ")" + } + } + store := recoveryStoreOf(h.stores) + capability, err := h.recoveryCapabilityOf(c.Request.Context(), store, in.owner, in.chain) + if err != nil { + fail(err.Error()) + return res + } + if allowed, reason := recoveryModeAllowed(in.mode, capability); !allowed { + fail(reason) + return res + } + // The intent precedes the mutation: a submission that cannot be logged is + // never written to the verifier's database. + if err := h.recordAction(c, Action{ + Action: "recovery-submit", Target: target, + OperationID: in.requestID, Outcome: "started", + Detail: fmt.Sprintf("submitting mode=%s blocks %d–%s", in.mode, in.from, recoveryAutoTo(in.to)), + }); err != nil { + res.Error = "not submitted — action log unavailable: " + err.Error() + return res + } + op, err := store.Submit(c.Request.Context(), recovery.SubmitRequest{ + ID: in.requestID, OwnerID: in.owner, SourceChain: in.chain, Mode: in.mode, + FromBlock: in.from, ToBlock: in.to, Actor: h.actor(c), Note: in.note, + }) + if err != nil { + fail("submission rejected: " + err.Error()) + return res + } + res.OperationID = op.ID + res.State = op.State + res.ToBlock = strconv.FormatUint(op.ToBlock, 10) + detail := fmt.Sprintf("mode=%s state=%s to_block=%d", op.Mode, op.State, op.ToBlock) + if err := h.recordAction(c, Action{ + Action: "recovery-submit", Target: target, + OperationID: op.ID, Outcome: "success", Detail: detail, + }); err != nil { + res.Error = "operation " + op.ID + " was created but the action log write failed: " + err.Error() + } + return res +} + +// recoveryAutoTo renders the form's to-block for action detail; "auto" when omitted. +func recoveryAutoTo(to *uint64) string { + if to == nil { + return "auto" + } + return strconv.FormatUint(*to, 10) +} + +func recoveryTarget(in recoveryFormInput) string { + return fmt.Sprintf("owner=%s chain=%s blocks=%s-%s mode=%s", in.owner, in.chain, strconv.FormatUint(in.from, 10), recoveryAutoTo(in.to), in.mode) +} + +func (h *handlers) recoveryOperations(c *gin.Context) { + owner := strings.TrimSpace(c.Query("owner")) + chain := strings.TrimSpace(c.Query("chain")) + if chain != "" { + if _, err := strconv.ParseUint(chain, 10, 64); err != nil { + h.render(c, http.StatusBadRequest, views.RecoveryPreviewError("chain filter must be an unsigned decimal chain selector: "+err.Error())) + return + } + } + var vm views.RecoveryOperationsVM + ops, err := recoveryStoreOf(h.stores).List(c.Request.Context(), owner, chain, 25) + if err != nil { + vm.Error = err.Error() + } else { + for _, op := range ops { + vm.Ops = append(vm.Ops, recoveryOperationVM(op)) + if op.State == "accepted" || op.State == "running" { + vm.InFlight = true + } + } + } + h.render(c, http.StatusOK, views.RecoveryOperations(vm, h.csrfToken(c))) +} + +func recoveryOperationVM(o recovery.Operation) views.RecoveryOperationVM { + vm := views.RecoveryOperationVM{ + ID: o.ID, Mode: o.Mode, State: o.State, Actor: o.Actor, Note: o.Note, + RangeFrom: strconv.FormatUint(o.FromBlock, 10), RangeTo: strconv.FormatUint(o.ToBlock, 10), + LastError: o.LastError, ResetApplied: o.ResetApplied, UpdatedAt: o.UpdatedAt, + Counters: fmt.Sprintf("admitted %d · dropped %d · conflicts %d · filtered %d · errors %d", + o.Admitted, o.Dropped, o.Conflicts, o.Filtered, o.Errors), + } + vm.Progress = recoveryProgress(o) + vm.CanCancel = o.State == "accepted" || o.State == "running" || o.State == "blocked" || o.State == "failed" + vm.CanResume = o.State == "cancelled" || o.State == "failed" || o.State == "blocked" + return vm +} + +// recoveryProgress renders NextBlock against the inclusive target range. +func recoveryProgress(o recovery.Operation) string { + if o.State == "completed" { + return "complete" + } + total := o.ToBlock - o.FromBlock + 1 + done := uint64(0) + if o.NextBlock > o.FromBlock { + done = min(o.NextBlock-o.FromBlock, total) + } + return fmt.Sprintf("next %d of %d–%d (%d%%)", o.NextBlock, o.FromBlock, o.ToBlock, done*100/total) +} + +func (h *handlers) recoveryCancel(c *gin.Context) { h.recoveryChangeState(c, "cancel") } +func (h *handlers) recoveryResume(c *gin.Context) { h.recoveryChangeState(c, "resume") } + +// recoveryChangeState applies cancel/resume via the durable store and re-renders the +// row; nothing about the operation is held in console memory. The action intent is +// logged before the state change; an unloggable mutation does not proceed. +func (h *handlers) recoveryChangeState(c *gin.Context, action string) { + rowErr := func(status int, id, detail string) { + h.render(c, status, views.RecoveryOperationRow(views.RecoveryOperationVM{ID: id, RowError: detail}, h.csrfToken(c))) + } + id := c.Param("id") + if _, err := uuid.Parse(id); err != nil { + rowErr(http.StatusBadRequest, id, "operation ID must be a UUID.") + return + } + store := recoveryStoreOf(h.stores) + // The intent precedes the state change: an unloggable mutation does not proceed. + if err := h.recordAction(c, Action{ + Action: "recovery-" + action, Target: id, + OperationID: id, Outcome: "started", Detail: action + " requested", + }); err != nil { + rowErr(http.StatusServiceUnavailable, id, "not executed — action log unavailable: "+err.Error()) + return + } + op, err := store.ChangeState(c.Request.Context(), id, action) + outcome, detail := "success", "" + if err != nil { + outcome, detail = "failed", err.Error() + } else { + detail = "state=" + op.State + } + target := op.OwnerID + "/" + op.SourceChain + if err == nil && op.ID == "" || err != nil { + target = id + } + if logErr := h.recordAction(c, Action{ + Action: "recovery-" + action, Target: target, + OperationID: id, Outcome: outcome, Detail: detail, + }); logErr != nil { + detail += " (action log write failed: " + logErr.Error() + ")" + } + if err == nil { + h.render(c, http.StatusOK, views.RecoveryOperationRow(recoveryOperationVM(op), h.csrfToken(c))) + return + } + current, getErr := store.Get(c.Request.Context(), id) + if getErr != nil { + rowErr(http.StatusConflict, id, action+" failed: "+detail) + return + } + vm := recoveryOperationVM(current) + vm.LastError = strings.TrimSpace(vm.LastError + " " + action + " failed: " + detail) + h.render(c, http.StatusConflict, views.RecoveryOperationRow(vm, h.csrfToken(c))) +} + +func (h *handlers) recoveryEvidence(c *gin.Context) { + filter, err := parseRecoveryEvidenceQuery(c) + if err != nil { + h.render(c, http.StatusBadRequest, views.RecoveryPreviewError(err.Error())) + return + } + h.render(c, http.StatusOK, views.RecoveryEvidence(h.recoveryEvidenceNode(c.Request.Context(), filter))) +} + +func parseRecoveryEvidenceQuery(c *gin.Context) (recovery.EventFilter, error) { + filter := recovery.EventFilter{ + OwnerID: strings.TrimSpace(c.Query("owner")), SourceChain: strings.TrimSpace(c.Query("chain")), + FromBlock: strings.TrimSpace(c.Query("from_block")), ToBlock: strings.TrimSpace(c.Query("to_block")), + BeforeID: strings.TrimSpace(c.Query("before_id")), Limit: 100, + } + for _, raw := range []string{filter.SourceChain, filter.FromBlock, filter.ToBlock, filter.BeforeID} { + if raw != "" { + if _, err := strconv.ParseUint(raw, 10, 64); err != nil { + return filter, fmt.Errorf("evidence filters (chain, blocks, cursor) must be unsigned decimal integers: %w", err) + } + } + } + if filter.FromBlock != "" && filter.ToBlock != "" { + from, _ := strconv.ParseUint(filter.FromBlock, 10, 64) + to, _ := strconv.ParseUint(filter.ToBlock, 10, 64) + if from > to { + return filter, fmt.Errorf("from-block (%d) must not be after to-block (%d)", from, to) + } + } + return filter, nil +} + +func (h *handlers) recoveryEvidenceNode(ctx context.Context, filter recovery.EventFilter) views.RecoveryEvidenceVM { + vm := views.RecoveryEvidenceVM{} + page, err := recoveryStoreOf(h.stores).ListEvents(ctx, filter) + if err != nil { + vm.Error = err.Error() + return vm + } + vm.Coverage = page.Coverage + vm.RetainedSince = page.RetainedSince.UTC().Format(time.RFC3339) + vm.NextCursor = page.NextCursor + vm.Readers = recoveryReaderVMs(page.Readers) + for _, e := range page.Events { + vm.Events = append(vm.Events, recoveryEventVM(e)) + } + return vm +} + +func recoveryReaderVMs(raw json.RawMessage) []views.RecoveryReaderVM { + var readers []recoveryReaderInfo + if len(raw) == 0 || json.Unmarshal(raw, &readers) != nil { + return nil + } + vms := make([]views.RecoveryReaderVM, 0, len(readers)) + for _, r := range readers { + vms = append(vms, views.RecoveryReaderVM{ + NodeID: r.NodeID, Disabled: strconv.FormatBool(r.Disabled), + LatestBlock: recoveryDeref(r.LatestBlock), + HeadObservedAt: recoveryTimeVM(r.HeadObservedAt), + LastSeenAt: recoveryTimeVM(r.LastSeenAt), + HistoryStartedAt: recoveryTimeVM(r.HistoryStartedAt), + ActiveResetID: recoveryDeref(r.ActiveResetID), + AuditFailures: r.AuditFailures, + }) + } + return vms +} + +func recoveryEventVM(e recovery.Event) views.RecoveryEventVM { + return views.RecoveryEventVM{ + Kind: e.Kind, Stage: e.Stage, Reason: e.Reason, + SourceBlock: recoveryDeref(e.SourceBlock), MessageID: recoveryDeref(e.MessageID), + TxHash: recoveryDeref(e.TxHash), BlockHash: recoveryDeref(e.BlockHash), IncidentID: recoveryDeref(e.IncidentID), + Observations: e.Observations, + FirstObserved: e.FirstObservedAt.UTC().Format(time.RFC3339), + LastObserved: e.LastObservedAt.UTC().Format(time.RFC3339), + Expires: e.ExpiresAt.UTC().Format(time.RFC3339), + } +} + +func recoveryDeref(s *string) string { + if s == nil || *s == "" { + return "—" + } + return *s +} + +func recoveryTimeVM(t *time.Time) string { + if t == nil { + return "—" + } + return t.UTC().Format(time.RFC3339) +} diff --git a/verifier/pkg/admin/recoveryops_test.go b/verifier/pkg/admin/recoveryops_test.go new file mode 100644 index 000000000..8eb3cecba --- /dev/null +++ b/verifier/pkg/admin/recoveryops_test.go @@ -0,0 +1,399 @@ +package admin + +import ( + "context" + "database/sql" + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" + + recoverycli "github.com/smartcontractkit/chainlink-ccv/cli/recovery" + "github.com/smartcontractkit/chainlink-ccv/protocol" + "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/chainstatus" + "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/recovery" + "github.com/smartcontractkit/chainlink-common/pkg/logger" +) + +// recoveryStoreStub implements recoverycli.Store with per-method hooks. +type recoveryStoreStub struct { + submitFn func(context.Context, recovery.SubmitRequest) (recovery.Operation, error) + getFn func(context.Context, string) (recovery.Operation, error) + listFn func(context.Context, string, string, int) ([]recovery.Operation, error) + changeStateFn func(context.Context, string, string) (recovery.Operation, error) + listEventsFn func(context.Context, recovery.EventFilter) (recovery.EventPage, error) +} + +func (f *recoveryStoreStub) Submit(ctx context.Context, r recovery.SubmitRequest) (recovery.Operation, error) { + if f.submitFn == nil { + return recovery.Operation{}, errors.New("unexpected Submit call") + } + return f.submitFn(ctx, r) +} + +func (f *recoveryStoreStub) Get(ctx context.Context, id string) (recovery.Operation, error) { + if f.getFn == nil { + return recovery.Operation{}, errors.New("unexpected Get call") + } + return f.getFn(ctx, id) +} + +func (f *recoveryStoreStub) List(ctx context.Context, owner, chain string, limit int) ([]recovery.Operation, error) { + if f.listFn == nil { + return nil, errors.New("unexpected List call") + } + return f.listFn(ctx, owner, chain, limit) +} + +func (f *recoveryStoreStub) ChangeState(ctx context.Context, id, action string) (recovery.Operation, error) { + if f.changeStateFn == nil { + return recovery.Operation{}, errors.New("unexpected ChangeState call") + } + return f.changeStateFn(ctx, id, action) +} + +func (f *recoveryStoreStub) ListEvents(ctx context.Context, filter recovery.EventFilter) (recovery.EventPage, error) { + if f.listEventsFn == nil { + return recovery.EventPage{}, errors.New("unexpected ListEvents call") + } + return f.listEventsFn(ctx, filter) +} + +type recoveryChainStatusesStub struct { + rows []chainstatus.Row + err error +} + +func (f recoveryChainStatusesStub) List(context.Context) ([]chainstatus.Row, error) { + return f.rows, f.err +} + +// captureSQLConnector is a minimal in-memory driver.Conn source so ActionLog writes +// can be asserted without a database. +type captureSQLConnector struct { + mu sync.Mutex + execs [][]driver.NamedValue + execErr error +} + +func (c *captureSQLConnector) Connect(context.Context) (driver.Conn, error) { + return captureSQLConn{c}, nil +} +func (c *captureSQLConnector) Driver() driver.Driver { return captureSQLDriver{} } + +type captureSQLDriver struct{} + +func (captureSQLDriver) Open(string) (driver.Conn, error) { return nil, errors.New("use Connector") } + +type captureSQLConn struct{ c *captureSQLConnector } + +func (captureSQLConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("no prepare") } +func (captureSQLConn) Close() error { return nil } +func (captureSQLConn) Begin() (driver.Tx, error) { return nil, errors.New("no tx") } + +func (c captureSQLConn) ExecContext(_ context.Context, _ string, args []driver.NamedValue) (driver.Result, error) { + c.c.mu.Lock() + defer c.c.mu.Unlock() + if c.c.execErr != nil { + return nil, c.c.execErr + } + c.c.execs = append(c.c.execs, append([]driver.NamedValue(nil), args...)) + return driver.RowsAffected(1), nil +} + +func (c *captureSQLConnector) execValues(t *testing.T, i int) []any { + t.Helper() + c.mu.Lock() + defer c.mu.Unlock() + require.Less(t, i, len(c.execs), "expected at least %d recorded execs", i+1) + vals := make([]any, 0, len(c.execs[i])) + for _, a := range c.execs[i] { + vals = append(vals, a.Value) + } + return vals +} + +func newCaptureActionLog(t *testing.T) (*ActionLog, *captureSQLConnector) { + t.Helper() + conn := &captureSQLConnector{} + db := sql.OpenDB(conn) + t.Cleanup(func() { _ = db.Close() }) + return NewActionLog(sqlx.NewDb(db, "postgres")), conn +} + +// readerPageJSON mirrors the ccv_recovery_readers jsonb the store embeds in EventPage. +func readerPageJSON(disabled bool) json.RawMessage { + now := time.Now().UTC().Format(time.RFC3339) + return json.RawMessage(fmt.Sprintf(`[{"owner_id":"owner-1","source_chain_selector":"1","node_id":"host-a",`+ + `"latest_block":"2000","head_observed_at":%q,"last_seen_at":%q,"history_started_at":%q,`+ + `"disabled":%t,"active_reset_id":null,"audit_failures":"0","last_audit_failure_at":null}]`, now, now, now, disabled)) +} + +func enabledChainStatuses() recoveryChainStatusesStub { + return recoveryChainStatusesStub{rows: []chainstatus.Row{{ + ChainSelector: protocol.ChainSelector(1), VerifierID: "owner-1", + FinalizedBlockHeight: big.NewInt(1500), Disabled: false, UpdatedAt: time.Now(), + }}} +} + +func newRecoveryTestRouter(t *testing.T, store recoverycli.Store, statuses chainStatusLister, actions *ActionLog) *gin.Engine { + t.Helper() + oldStore, oldStatuses := recoveryStoreOf, chainStatusesOf + recoveryStoreOf = func(stores) recoverycli.Store { return store } + chainStatusesOf = func(stores) chainStatusLister { return statuses } + t.Cleanup(func() { recoveryStoreOf, chainStatusesOf = oldStore, oldStatuses }) + + gin.SetMode(gin.TestMode) + h := &handlers{cfg: &Config{}, lggr: logger.Test(t), actions: actions} + r := gin.New() + h.registerRecoveryRoutes(r) + return r +} + +func postForm(r *gin.Engine, path string, form url.Values) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + return rec +} + +func recoverySubmitForm(mode string) url.Values { + return url.Values{ + "owner": {"owner-1"}, + "chain": {"1"}, + "from_block": {"100"}, + "to_block": {"200"}, + "mode": {mode}, + "note": {"canonical headers checked through 99; incident INC-7"}, + } +} + +func TestRecoveryReplayBlockedWhenReaderDisabled(t *testing.T) { + actions, captured := newCaptureActionLog(t) + submitCalls := 0 + store := &recoveryStoreStub{ + listEventsFn: func(context.Context, recovery.EventFilter) (recovery.EventPage, error) { + return recovery.EventPage{Readers: readerPageJSON(true), RetainedSince: time.Now()}, nil + }, + submitFn: func(context.Context, recovery.SubmitRequest) (recovery.Operation, error) { + submitCalls++ + return recovery.Operation{ID: "11111111-1111-1111-1111-111111111111", State: "accepted", Mode: "reset-reader", ToBlock: 200}, nil + }, + } + r := newRecoveryTestRouter(t, store, enabledChainStatuses(), actions) + + rec := postForm(r, "/recovery/submit", recoverySubmitForm("replay")) + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, rec.Body.String(), "reset-reader") + require.Contains(t, rec.Body.String(), "finality-blocked") + require.Zero(t, submitCalls, "replay must be refused before touching the store") + + // The refusal is audited as a failed recovery-submit. + vals := captured.execValues(t, 0) + require.Equal(t, "recovery-submit", vals[1]) + require.Equal(t, "failed", vals[4]) + + // reset-reader is the allowed investigated action for the same disabled reader. + rec = postForm(r, "/recovery/submit", recoverySubmitForm("reset-reader")) + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, rec.Body.String(), "11111111-1111-1111-1111-111111111111") + require.Equal(t, 1, submitCalls) +} + +func TestRecoverySubmitRecordsActionLogWithOperationID(t *testing.T) { + actions, captured := newCaptureActionLog(t) + opID := "22222222-2222-2222-2222-222222222222" + var gotReq recovery.SubmitRequest + store := &recoveryStoreStub{ + listEventsFn: func(context.Context, recovery.EventFilter) (recovery.EventPage, error) { + return recovery.EventPage{Readers: readerPageJSON(false), RetainedSince: time.Now()}, nil + }, + submitFn: func(_ context.Context, req recovery.SubmitRequest) (recovery.Operation, error) { + gotReq = req + return recovery.Operation{ID: opID, OwnerID: req.OwnerID, SourceChain: req.SourceChain, State: "accepted", Mode: req.Mode, ToBlock: 200}, nil + }, + } + r := newRecoveryTestRouter(t, store, enabledChainStatuses(), actions) + + rec := postForm(r, "/recovery/submit", recoverySubmitForm("replay")) + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, rec.Body.String(), opID) + + require.Equal(t, "owner-1", gotReq.OwnerID) + require.Equal(t, "1", gotReq.SourceChain) + require.Equal(t, uint64(100), gotReq.FromBlock) + require.NotNil(t, gotReq.ToBlock) + require.Equal(t, uint64(200), *gotReq.ToBlock) + require.Equal(t, "local", gotReq.Actor) + require.NotEmpty(t, gotReq.Note) + require.NotEmpty(t, gotReq.ID, "fresh request ID generated when none resubmitted") + + // The intent row precedes the submission; the outcome row follows it. + // Column order of ActionLog.Record's INSERT: actor, action, target, op, outcome, detail. + intent := captured.execValues(t, 0) + require.Equal(t, "recovery-submit", intent[1]) + require.Contains(t, intent[2], "owner=owner-1") + require.Equal(t, "started", intent[4]) + + vals := captured.execValues(t, 1) + require.Equal(t, "local", vals[0]) + require.Equal(t, "recovery-submit", vals[1]) + require.Equal(t, opID, vals[3]) + require.Equal(t, "success", vals[4]) +} + +func TestRecoveryCancelResumeMapToChangeStateAndLog(t *testing.T) { + actions, captured := newCaptureActionLog(t) + opID := "33333333-3333-3333-3333-333333333333" + var gotID, gotAction string + store := &recoveryStoreStub{ + changeStateFn: func(_ context.Context, id, action string) (recovery.Operation, error) { + gotID, gotAction = id, action + state := "cancelled" + if action == "resume" { + state = "accepted" + } + return recovery.Operation{ID: id, OwnerID: "owner-1", SourceChain: "1", FromBlock: 100, ToBlock: 200, State: state}, nil + }, + } + r := newRecoveryTestRouter(t, store, enabledChainStatuses(), actions) + + rec := postForm(r, "/recovery/operations/"+opID+"/cancel", url.Values{}) + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, rec.Body.String(), "cancelled") + require.Equal(t, opID, gotID) + require.Equal(t, "cancel", gotAction) + intent := captured.execValues(t, 0) + require.Equal(t, "recovery-cancel", intent[1]) + require.Equal(t, "started", intent[4]) + vals := captured.execValues(t, 1) + require.Equal(t, "recovery-cancel", vals[1]) + require.Equal(t, opID, vals[3]) + require.Equal(t, "success", vals[4]) + + rec = postForm(r, "/recovery/operations/"+opID+"/resume", url.Values{}) + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, rec.Body.String(), "accepted") + require.Equal(t, "resume", gotAction) + intent = captured.execValues(t, 2) + require.Equal(t, "recovery-resume", intent[1]) + require.Equal(t, "started", intent[4]) + vals = captured.execValues(t, 3) + require.Equal(t, "recovery-resume", vals[1]) + require.Equal(t, opID, vals[3]) + require.Equal(t, "success", vals[4]) +} + +func TestRecoveryOperationsReadsOnlyStoreState(t *testing.T) { + opID := "44444444-4444-4444-4444-444444444444" + store := &recoveryStoreStub{ + listFn: func(_ context.Context, owner, chain string, limit int) ([]recovery.Operation, error) { + return []recovery.Operation{{ + ID: opID, OwnerID: "owner-1", SourceChain: "1", FromBlock: 100, ToBlock: 200, NextBlock: 150, + Mode: "replay", State: "running", Admitted: 7, UpdatedAt: time.Now(), + }}, nil + }, + } + + // Two independent handler instances (a "reload") render identically: operation + // state comes only from the store, never from console memory. + for i := range 2 { + r := newRecoveryTestRouter(t, store, enabledChainStatuses(), nil) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/recovery/operations", nil) + r.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code, "iteration %d", i) + body := rec.Body.String() + require.Contains(t, body, opID, "iteration %d", i) + require.Contains(t, body, "running", "iteration %d", i) + require.Contains(t, body, "next 150 of 100–200", "iteration %d", i) + } +} + +func TestRecoverySubmitRejectsFromAfterTo(t *testing.T) { + actions, _ := newCaptureActionLog(t) + store := &recoveryStoreStub{ + submitFn: func(context.Context, recovery.SubmitRequest) (recovery.Operation, error) { + t.Fatal("Submit must not be called for an inverted range") + return recovery.Operation{}, nil + }, + } + r := newRecoveryTestRouter(t, store, enabledChainStatuses(), actions) + + form := recoverySubmitForm("replay") + form.Set("from_block", "300") + rec := postForm(r, "/recovery/submit", form) + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Contains(t, rec.Body.String(), "must not be after") + + form = recoverySubmitForm("replay") + form.Set("note", "") + rec = postForm(r, "/recovery/submit", form) + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Contains(t, rec.Body.String(), "note is required") +} + +func TestRecoveryEvidenceRendersCoverageGapText(t *testing.T) { + store := &recoveryStoreStub{ + listEventsFn: func(_ context.Context, f recovery.EventFilter) (recovery.EventPage, error) { + require.Equal(t, "owner-1", f.OwnerID) + require.Equal(t, "1", f.SourceChain) + return recovery.EventPage{ + Events: nil, + RetainedSince: time.Now().Add(-recovery.HistoryRetention), + Coverage: "Observed events only. Empty results do not prove no affected traffic.", + Readers: readerPageJSON(false), + }, nil + }, + } + r := newRecoveryTestRouter(t, store, enabledChainStatuses(), nil) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/recovery/evidence?owner=owner-1&chain=1", nil) + r.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + require.Contains(t, body, "Observed events only") + require.Contains(t, body, "You may still scope and submit a manual range") + require.Contains(t, body, "Absence of evidence never proves") + + // The page itself carries the "evidence is not the earliest affected block" guidance. + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/recovery", nil) + r.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, rec.Body.String(), "not automatically the earliest affected block") +} + +func TestRecoveryPreviewCapabilityView(t *testing.T) { + store := &recoveryStoreStub{ + listEventsFn: func(context.Context, recovery.EventFilter) (recovery.EventPage, error) { + return recovery.EventPage{Readers: readerPageJSON(true), RetainedSince: time.Now()}, nil + }, + } + r := newRecoveryTestRouter(t, store, enabledChainStatuses(), nil) + + form := recoverySubmitForm("replay") + form.Set("to_block", "249") // 150 blocks → 2 chunks of ≤100 + rec := postForm(r, "/recovery/preview", form) + require.Equal(t, http.StatusOK, rec.Code) + body := rec.Body.String() + require.Contains(t, body, "150 block(s), processed as 2 chunk(s)") + require.Contains(t, body, "may revisit already-attested traffic") // from 100 < finalized 1500 + require.Contains(t, body, "disabled (finality-blocked)") + require.Contains(t, body, "Submit unavailable") + require.NotContains(t, body, `hx-post="/recovery/submit"`, "no enabled submit path when blocked") +} diff --git a/verifier/pkg/admin/reschedule.go b/verifier/pkg/admin/reschedule.go new file mode 100644 index 000000000..73892293f --- /dev/null +++ b/verifier/pkg/admin/reschedule.go @@ -0,0 +1,283 @@ +package admin + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "github.com/smartcontractkit/chainlink-ccv/cli/jobqueue" + "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/admin/views" +) + +// Reschedule: preview the exact owners/jobs a reschedule affects, recheck attestation +// state before mutating (unknown ≠ needs replay), execute one owner-scoped operation +// per target, report per-target results. Every execution re-runs the archive and +// attestation gate; no path skips it. + +// rescheduleTarget is one parsed `target` form field, pipe-separated as emitted by the +// message detail page: jobID|messageIDHex|queue|ownerID. +type rescheduleTarget struct { + JobID string + MessageID []byte + MessageIDHex string + Queue jobqueue.QueueType + OwnerID string +} + +func parseRescheduleTarget(raw string) (rescheduleTarget, error) { + var t rescheduleTarget + parts := strings.SplitN(raw, "|", 4) + if len(parts) != 4 { + return t, fmt.Errorf("expected jobID|messageID|queue|ownerID, got %d fields", len(parts)) + } + t.JobID, t.OwnerID = parts[0], parts[3] + id, err := jobqueue.ParseMessageID(parts[1]) + if err != nil || len(id) != 32 { + return t, fmt.Errorf("invalid message ID %q: expected a full 0x-prefixed 32-byte hex ID", parts[1]) + } + t.MessageID = id + t.MessageIDHex = formatMessageID(id) + t.Queue = jobqueue.QueueType(parts[2]) + if t.Queue != jobqueue.QueueTypeTaskVerifier && t.Queue != jobqueue.QueueTypeStorageWriter { + return t, fmt.Errorf("unknown queue %q", parts[2]) + } + if t.JobID == "" || t.OwnerID == "" { + return t, fmt.Errorf("job ID and owner must both be non-empty") + } + return t, nil +} + +func parseRetryDuration(raw string) (time.Duration, error) { + if strings.TrimSpace(raw) == "" { + return time.Hour, nil + } + d, err := time.ParseDuration(raw) + if err != nil || d <= 0 { + return 0, fmt.Errorf("invalid retry_duration %q: must be a positive duration (e.g. 30m, 1h)", raw) + } + return d, nil +} + +// jobQueueStore is a var so tests can substitute a fake without a database. +var jobQueueStore = func(s stores) jobqueue.Store { return s.JobQueue() } + +func (h *handlers) registerRescheduleRoutes(r *gin.Engine) { + r.POST("/reschedule/preview", h.reschedulePreview) + r.POST("/reschedule/execute", h.rescheduleExecute) +} + +// recheckState is the pre-mutation verdict for one target. +type recheckState string + +const ( + recheckExecutable recheckState = "executable" // still failed and not attested + recheckSkip recheckState = "skip" // genuinely nothing to do + recheckUnknown recheckState = "unknown" // cannot prove a replay is needed +) + +// recheckArchiveRow confirms the target's archive row still exists as a failed job +// belonging to the claimed owner. +func recheckArchiveRow(ctx context.Context, store jobqueue.Store, t rescheduleTarget) (recheckState, string, *jobqueue.ArchivedJob) { + jobs, err := store.ListFailedFiltered(ctx, []jobqueue.QueueType{t.Queue}, t.OwnerID, [][]byte{t.MessageID}, 0) + if err != nil { + return recheckUnknown, "archive lookup failed: " + err.Error(), nil + } + for i := range jobs { + if jobs[i].JobID == t.JobID && jobs[i].OwnerID == t.OwnerID { + return recheckExecutable, "", &jobs[i] + } + } + return recheckSkip, "no matching failed archive row — already rescheduled or expired", nil +} + +// previewTarget carries one target plus its recheck verdict into the view model. +type previewTarget struct { + target rescheduleTarget + raw string + state recheckState + detail string + job *jobqueue.ArchivedJob +} + +func (h *handlers) reschedulePreview(c *gin.Context) { + fullPage := c.GetHeader("HX-Request") == "" + raws := c.PostFormArray("target") + if len(raws) == 0 { + h.render(c, http.StatusBadRequest, views.ReschedulePreview(h.csrfToken(c), nil, "No targets submitted.", fullPage)) + return + } + pts := make([]previewTarget, 0, len(raws)) + for _, raw := range raws { + pt := previewTarget{raw: raw} + t, err := parseRescheduleTarget(raw) + if err != nil { + pt.state, pt.detail = recheckSkip, "invalid target: "+err.Error() + pts = append(pts, pt) + continue + } + pt.target = t + pt.state, pt.detail, pt.job = recheckArchiveRow(c.Request.Context(), jobQueueStore(h.stores), t) + pts = append(pts, pt) + } + h.recheckAttestations(c.Request.Context(), pts) + h.render(c, http.StatusOK, views.ReschedulePreview(h.csrfToken(c), reschedulePreviewVMs(pts), "", fullPage)) +} + +// recheckAttestations runs the freshness check over the targets that passed the +// archive recheck. Attested targets are excluded from the executable set; unknown +// disables the target rather than proving a replay is needed. +func (h *handlers) recheckAttestations(ctx context.Context, pts []previewTarget) { + var indexes []int + for i := range pts { + if pts[i].state == recheckExecutable { + indexes = append(indexes, i) + } + } + if len(indexes) == 0 { + return + } + ids := make([][]byte, len(indexes)) + for j, idx := range indexes { + ids[j] = pts[idx].target.MessageID + } + results := checkAttestations(ctx, h.aggregatorAddress, ids) + for j, idx := range indexes { + switch results[j].State { + case AttestationAttested: + pts[idx].state = recheckSkip + pts[idx].detail = "already attested — nothing to do (" + results[j].Detail + ")" + case AttestationUnknown: + pts[idx].state = recheckUnknown + pts[idx].detail = "attestation state unknown: " + results[j].Detail + default: + pts[idx].detail = results[j].Detail + } + } +} + +func reschedulePreviewVMs(pts []previewTarget) []views.RescheduleTargetVM { + vms := make([]views.RescheduleTargetVM, 0, len(pts)) + for _, pt := range pts { + vm := views.RescheduleTargetVM{ + Target: pt.raw, + OwnerID: pt.target.OwnerID, + Queue: string(pt.target.Queue), + JobID: pt.target.JobID, + MessageID: pt.target.MessageIDHex, + Detail: pt.detail, + } + if pt.job != nil { + vm.FailureCategory = pt.job.FailureCategory + } + switch pt.state { + case recheckExecutable: + vm.Executable = true + vm.Status = "ready" + case recheckUnknown: + vm.Status = "unknown" + default: + vm.Status = "excluded" + } + vms = append(vms, vm) + } + return vms +} + +// executeOutcome is one target's mutation result for the results fragment. +type executeOutcome struct { + target rescheduleTarget + raw string + outcome string // success | failed | skipped + detail string +} + +func (h *handlers) rescheduleExecute(c *gin.Context) { + fullPage := c.GetHeader("HX-Request") == "" + retryDuration, err := parseRetryDuration(c.PostForm("retry_duration")) + if err != nil { + h.render(c, http.StatusBadRequest, views.RescheduleResults(h.csrfToken(c), nil, "", "", err.Error(), fullPage)) + return + } + raws := c.PostFormArray("target") + if len(raws) == 0 { + h.render(c, http.StatusBadRequest, views.RescheduleResults(h.csrfToken(c), nil, "", "", "No targets selected.", fullPage)) + return + } + + // One target at a time: the gate, the intent row, the mutation, then the + // outcome row — so the audit log reads as adjacent intent/outcome pairs. + var auditErrs []string + resultVMs := make([]views.RescheduleResultVM, 0, len(raws)) + for _, raw := range raws { + t, perr := parseRescheduleTarget(raw) + if perr != nil { + detail := "invalid target: " + perr.Error() + if err := h.recordAction(c, Action{Action: "reschedule", Target: raw, Outcome: "failed", Detail: detail}); err != nil { + auditErrs = append(auditErrs, fmt.Sprintf("%s: %v", raw, err)) + } + resultVMs = append(resultVMs, views.RescheduleResultVM{Target: raw, Outcome: "failed", Detail: detail}) + continue + } + o := h.executeTarget(c.Request.Context(), c, t, raw, retryDuration) + logTarget := o.target.MessageIDHex + if err := h.recordAction(c, Action{ + Action: "reschedule", Target: logTarget, + Outcome: o.outcome, Detail: o.detail, + }); err != nil { + auditErrs = append(auditErrs, fmt.Sprintf("%s: %v", logTarget, err)) + } + resultVMs = append(resultVMs, views.RescheduleResultVM{ + Target: o.raw, OwnerID: o.target.OwnerID, + Queue: string(o.target.Queue), JobID: o.target.JobID, MessageID: o.target.MessageIDHex, + Outcome: o.outcome, Detail: o.detail, + }) + } + h.render(c, http.StatusOK, views.RescheduleResults(h.csrfToken(c), resultVMs, retryDuration.String(), strings.Join(auditErrs, "; "), "", fullPage)) +} + +// executeTarget performs one owner-scoped reschedule per target. The safety gate +// (archive row plus attestation freshness) re-runs on every execution, and the +// intent is durably logged before the mutation itself. +func (h *handlers) executeTarget(ctx context.Context, c *gin.Context, t rescheduleTarget, raw string, retryDuration time.Duration) executeOutcome { + out := executeOutcome{target: t, raw: raw} + store := jobQueueStore(h.stores) + state, detail, _ := recheckArchiveRow(ctx, store, t) + if state == recheckExecutable { + att := checkAttestations(ctx, h.aggregatorAddress, [][]byte{t.MessageID})[0] + switch att.State { + case AttestationAttested: + state, detail = recheckSkip, "already attested — nothing to do ("+att.Detail+")" + case AttestationUnknown: + state, detail = recheckUnknown, "attestation state unknown: "+att.Detail + } + } + switch state { + case recheckSkip: + out.outcome, out.detail = "skipped", detail + return out + case recheckUnknown: + out.outcome, out.detail = "skipped", "not executed — "+detail + return out + } + // Log the intent before mutating: a mutation that cannot be logged does not + // proceed, and a later outcome-write failure still leaves the intent visible. + if err := h.recordAction(c, Action{ + Action: "reschedule", Target: t.MessageIDHex, + Outcome: "started", + Detail: fmt.Sprintf("restoring job %s (queue %s, owner %s)", t.JobID, t.Queue, t.OwnerID), + }); err != nil { + out.outcome, out.detail = "failed", "not executed — action log unavailable: "+err.Error() + return out + } + if err := store.RescheduleByJobID(ctx, t.Queue, t.OwnerID, t.JobID, retryDuration); err != nil { + out.outcome, out.detail = "failed", err.Error() + return out + } + out.outcome = "success" + out.detail = fmt.Sprintf("restored archive→active; attempts reset; new retry deadline %s from now", retryDuration) + return out +} diff --git a/verifier/pkg/admin/reschedule_test.go b/verifier/pkg/admin/reschedule_test.go new file mode 100644 index 000000000..7ad60efac --- /dev/null +++ b/verifier/pkg/admin/reschedule_test.go @@ -0,0 +1,494 @@ +package admin + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-ccv/cli/jobqueue" + "github.com/smartcontractkit/chainlink-ccv/integration/storageaccess" + "github.com/smartcontractkit/chainlink-common/pkg/logger" +) + +func rescheduleMsgID(b byte) []byte { + id := make([]byte, 32) + id[31] = b + return id +} + +func rescheduleTargetString(jobID string, id []byte, queue jobqueue.QueueType, owner string) string { + return strings.Join([]string{jobID, formatMessageID(id), string(queue), owner}, "|") +} + +// rescheduleFakeStore simulates the archive tables: a successful reschedule removes the +// row (moved to active), a failed one leaves it untouched. +type rescheduleFakeStore struct { + mu sync.Mutex + jobs []jobqueue.ArchivedJob + rescheduleErr map[string]error // jobID → error + calls []rescheduleCall +} + +type rescheduleCall struct { + queue jobqueue.QueueType + ownerID string + jobID string + dur time.Duration +} + +func (f *rescheduleFakeStore) ListFailed(context.Context, []jobqueue.QueueType, string, int) ([]jobqueue.ArchivedJob, error) { + return nil, nil +} + +func (f *rescheduleFakeStore) ListFailedFiltered(_ context.Context, queues []jobqueue.QueueType, ownerID string, messageIDs [][]byte, _ int) ([]jobqueue.ArchivedJob, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []jobqueue.ArchivedJob + for _, j := range f.jobs { + if ownerID != "" && j.OwnerID != ownerID { + continue + } + if len(queues) > 0 && !rescheduleQueueIn(queues, j.Queue) { + continue + } + if len(messageIDs) > 0 && !rescheduleMessageIDIn(messageIDs, j.MessageID) { + continue + } + out = append(out, j) + } + return out, nil +} + +func rescheduleQueueIn(queues []jobqueue.QueueType, q jobqueue.QueueType) bool { + return slices.Contains(queues, q) +} + +func rescheduleMessageIDIn(ids [][]byte, id []byte) bool { + for _, x := range ids { + if string(x) == string(id) { + return true + } + } + return false +} + +func (f *rescheduleFakeStore) Reschedule(context.Context, jobqueue.QueueType, string, string, []byte, time.Duration) (jobqueue.ArchivedJob, error) { + return jobqueue.ArchivedJob{}, errors.New("not implemented") +} + +func (f *rescheduleFakeStore) RescheduleByJobID(_ context.Context, queue jobqueue.QueueType, ownerID, jobID string, dur time.Duration) error { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, rescheduleCall{queue: queue, ownerID: ownerID, jobID: jobID, dur: dur}) + if err, ok := f.rescheduleErr[jobID]; ok { + return err + } + for i, j := range f.jobs { + if j.JobID == jobID { + f.jobs = append(f.jobs[:i], f.jobs[i+1:]...) + } + } + return nil +} + +func (f *rescheduleFakeStore) RescheduleByMessageID(context.Context, jobqueue.QueueType, string, []byte, time.Duration) error { + return errors.New("not implemented") +} + +// fakeSQLDriver backs the concrete ActionLog without a database: ExecContext calls are +// captured so tests can assert the recorded action rows. +type fakeSQLDriver struct { + mu sync.Mutex + execs [][]driver.NamedValue + err error +} + +func (d *fakeSQLDriver) Open(string) (driver.Conn, error) { return &fakeSQLConn{d}, nil } +func (d *fakeSQLDriver) Connect(context.Context) (driver.Conn, error) { return &fakeSQLConn{d}, nil } +func (d *fakeSQLDriver) Driver() driver.Driver { return d } + +type fakeSQLConn struct{ d *fakeSQLDriver } + +func (c *fakeSQLConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("no statements") } +func (c *fakeSQLConn) Close() error { return nil } +func (c *fakeSQLConn) Begin() (driver.Tx, error) { return nil, errors.New("no transactions") } + +func (c *fakeSQLConn) ExecContext(_ context.Context, _ string, args []driver.NamedValue) (driver.Result, error) { + c.d.mu.Lock() + defer c.d.mu.Unlock() + if c.d.err != nil { + return nil, c.d.err + } + c.d.execs = append(c.d.execs, args) + return driver.RowsAffected(1), nil +} + +func (d *fakeSQLDriver) recorded() [][]driver.NamedValue { + d.mu.Lock() + defer d.mu.Unlock() + return append([][]driver.NamedValue(nil), d.execs...) +} + +var fakeDriverSeq atomic.Int64 + +func newFakeActionLog(execErr error) (*ActionLog, *fakeSQLDriver) { + drv := &fakeSQLDriver{err: execErr} + sql.Register(fmt.Sprintf("ccv-admin-fake-%d", fakeDriverSeq.Add(1)), drv) + return NewActionLog(sqlx.NewDb(sql.OpenDB(drv), "postgres")), drv +} + +func newRescheduleTestHandlers(t *testing.T, store jobqueue.Store, actions *ActionLog) *handlers { + t.Helper() + if actions == nil { + actions, _ = newFakeActionLog(nil) + } + h := &handlers{lggr: logger.Test(t), actions: actions, aggregatorAddress: "agg:443"} + if store != nil { + orig := jobQueueStore + jobQueueStore = func(stores) jobqueue.Store { return store } + t.Cleanup(func() { jobQueueStore = orig }) + } + return h +} + +func reschedulePostContext(form url.Values) (*gin.Context, *httptest.ResponseRecorder) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + c.Request = req + c.Set("actor", "tester") + return c, rec +} + +func TestParseRescheduleTarget(t *testing.T) { + valid := rescheduleTargetString("job-1", rescheduleMsgID(1), jobqueue.QueueTypeTaskVerifier, "owner-1") + t.Run("valid", func(t *testing.T) { + target, err := parseRescheduleTarget(valid) + require.NoError(t, err) + require.Equal(t, "job-1", target.JobID) + require.Equal(t, "owner-1", target.OwnerID) + require.Equal(t, jobqueue.QueueTypeTaskVerifier, target.Queue) + require.Equal(t, rescheduleMsgID(1), target.MessageID) + require.Equal(t, formatMessageID(rescheduleMsgID(1)), target.MessageIDHex) + }) + for name, raw := range map[string]string{ + "too few fields": "job-1", + "bad message hex": "job-1|0xzz|task-verifier|owner-1", + "short message": "job-1|0x00|task-verifier|owner-1", + "bad queue": "job-1|" + formatMessageID(rescheduleMsgID(1)) + "|executor|owner-1", + "empty owner": "job-1|" + formatMessageID(rescheduleMsgID(1)) + "|task-verifier|", + } { + t.Run(name, func(t *testing.T) { + _, err := parseRescheduleTarget(raw) + require.Error(t, err) + }) + } +} + +func TestParseRetryDuration(t *testing.T) { + d, err := parseRetryDuration("") + require.NoError(t, err) + require.Equal(t, time.Hour, d, "empty defaults to 1h") + d, err = parseRetryDuration("30m") + require.NoError(t, err) + require.Equal(t, 30*time.Minute, d) + for _, raw := range []string{"abc", "0", "-5m", "0s"} { + _, err := parseRetryDuration(raw) + require.Error(t, err, raw) + } +} + +func TestReschedulePreviewExcludesAttested(t *testing.T) { + id := rescheduleMsgID(1) + store := &rescheduleFakeStore{jobs: []jobqueue.ArchivedJob{{ + JobID: "job-1", MessageID: id, OwnerID: "owner-1", + Queue: jobqueue.QueueTypeTaskVerifier, FailureCategory: "policy-timeout", + }}} + installFakeResultsClient(t, &fakeResultsClient{entries: []storageaccess.ResultEntry{ + {Present: true, CcvData: []byte{0x01}}, + }}) + h := newRescheduleTestHandlers(t, store, nil) + + c, rec := reschedulePostContext(url.Values{"target": {rescheduleTargetString("job-1", id, jobqueue.QueueTypeTaskVerifier, "owner-1")}}) + h.reschedulePreview(c) + + body := rec.Body.String() + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, body, "already attested — nothing to do") + require.Contains(t, body, "disabled") + require.NotContains(t, body, `name="target"`, "attested target must not be executable") + require.Contains(t, body, "policy-timeout") +} + +func TestReschedulePreviewUnknownDisablesTarget(t *testing.T) { + id := rescheduleMsgID(2) + store := &rescheduleFakeStore{jobs: []jobqueue.ArchivedJob{{ + JobID: "job-2", MessageID: id, OwnerID: "owner-1", Queue: jobqueue.QueueTypeStorageWriter, + }}} + installDialError(t, errors.New("connection refused")) + h := newRescheduleTestHandlers(t, store, nil) + + c, rec := reschedulePostContext(url.Values{"target": {rescheduleTargetString("job-2", id, jobqueue.QueueTypeStorageWriter, "owner-1")}}) + h.reschedulePreview(c) + + body := rec.Body.String() + require.Contains(t, body, "attestation state unknown") + require.Contains(t, body, "connection refused") + require.NotContains(t, body, `name="target"`, "unknown is never proof a replay is needed") +} + +func TestReschedulePreviewExecutableTarget(t *testing.T) { + id := rescheduleMsgID(3) + store := &rescheduleFakeStore{jobs: []jobqueue.ArchivedJob{{ + JobID: "job-3", MessageID: id, OwnerID: "owner-1", + Queue: jobqueue.QueueTypeTaskVerifier, FailureCategory: "source-rpc", + }}} + installFakeResultsClient(t, notFoundResultsClient()) + h := newRescheduleTestHandlers(t, store, nil) + + c, rec := reschedulePostContext(url.Values{"target": {rescheduleTargetString("job-3", id, jobqueue.QueueTypeTaskVerifier, "owner-1")}}) + h.reschedulePreview(c) + + body := rec.Body.String() + require.Contains(t, body, `name="target"`) + require.Contains(t, body, "checked") + require.Contains(t, body, "re-verifies the message") + require.Contains(t, body, "retries delivering the saved verification result") + require.Contains(t, body, "Neither re-checks source-chain finality") + require.Contains(t, body, "archive → active; attempts reset; new retry deadline") +} + +func TestReschedulePreviewSkipsMissingArchiveRow(t *testing.T) { + id := rescheduleMsgID(4) + store := &rescheduleFakeStore{} // archive empty + installFakeResultsClient(t, notFoundResultsClient()) + h := newRescheduleTestHandlers(t, store, nil) + + form := url.Values{"target": { + rescheduleTargetString("job-gone", id, jobqueue.QueueTypeTaskVerifier, "owner-1"), + }} + c, rec := reschedulePostContext(form) + h.reschedulePreview(c) + + body := rec.Body.String() + require.Contains(t, body, "no matching failed archive row") + require.NotContains(t, body, `name="target"`) +} + +func TestRescheduleExecuteActiveConflictPreservesArchive(t *testing.T) { + id := rescheduleMsgID(10) + conflict := errors.New("restore failed (an active job may already exist): duplicate key value") + store := &rescheduleFakeStore{ + jobs: []jobqueue.ArchivedJob{{JobID: "job-x", MessageID: id, OwnerID: "owner-1", Queue: jobqueue.QueueTypeTaskVerifier}}, + rescheduleErr: map[string]error{"job-x": conflict}, + } + installFakeResultsClient(t, notFoundResultsClient()) + actions, drv := newFakeActionLog(nil) + h := newRescheduleTestHandlers(t, store, actions) + + c, rec := reschedulePostContext(url.Values{"target": {rescheduleTargetString("job-x", id, jobqueue.QueueTypeTaskVerifier, "owner-1")}}) + h.rescheduleExecute(c) + + body := rec.Body.String() + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, body, "failed") + require.Contains(t, body, "active job may already exist") + require.Len(t, store.jobs, 1, "archive row preserved on conflict") + require.Len(t, store.calls, 1) + require.Equal(t, time.Hour, store.calls[0].dur, "default retry duration") + require.Equal(t, jobqueue.QueueTypeTaskVerifier, store.calls[0].queue) + require.Equal(t, "owner-1", store.calls[0].ownerID) + + // The intent row precedes the mutation; the outcome row follows it. + // Column order of ActionLog.Record's INSERT: actor, action, target, op, outcome, detail. + execs := drv.recorded() + require.Len(t, execs, 2) + require.Equal(t, "started", execs[0][4].Value) + require.Contains(t, execs[0][5].Value, "restoring job job-x") + require.Equal(t, "failed", execs[1][4].Value) + require.Equal(t, conflict.Error(), execs[1][5].Value) +} + +func TestRescheduleExecutePartialSuccess(t *testing.T) { + idA, idB := rescheduleMsgID(11), rescheduleMsgID(12) + store := &rescheduleFakeStore{ + jobs: []jobqueue.ArchivedJob{ + {JobID: "job-a", MessageID: idA, OwnerID: "owner-1", Queue: jobqueue.QueueTypeTaskVerifier}, + {JobID: "job-b", MessageID: idB, OwnerID: "owner-1", Queue: jobqueue.QueueTypeStorageWriter}, + }, + rescheduleErr: map[string]error{"job-b": errors.New("boom")}, + } + installFakeResultsClient(t, notFoundResultsClient()) + actions, drv := newFakeActionLog(nil) + h := newRescheduleTestHandlers(t, store, actions) + + tA := rescheduleTargetString("job-a", idA, jobqueue.QueueTypeTaskVerifier, "owner-1") + tB := rescheduleTargetString("job-b", idB, jobqueue.QueueTypeStorageWriter, "owner-1") + c, rec := reschedulePostContext(url.Values{"target": {tA, tB}, "retry_duration": {"30m"}}) + h.rescheduleExecute(c) + + body := rec.Body.String() + require.Contains(t, body, "success") + require.Contains(t, body, "boom") + require.Len(t, store.calls, 2, "both targets attempted independently") + require.Equal(t, 30*time.Minute, store.calls[0].dur) + require.Len(t, store.jobs, 1, "only the failed job stays archived") + require.Equal(t, "job-b", store.jobs[0].JobID) + + // The retry form carries only the non-success target. + require.Equal(t, 1, strings.Count(body, `name="target"`)) + + execs := drv.recorded() + require.Len(t, execs, 4, "intent and outcome row per target") + require.Equal(t, "started", execs[0][4].Value) + require.Equal(t, "success", execs[1][4].Value) + require.Equal(t, "started", execs[2][4].Value) + require.Equal(t, "failed", execs[3][4].Value) +} + +func TestRescheduleExecuteRetryFailedSkipsSuccesses(t *testing.T) { + idA, idB := rescheduleMsgID(13), rescheduleMsgID(14) + store := &rescheduleFakeStore{ + jobs: []jobqueue.ArchivedJob{ + {JobID: "job-a", MessageID: idA, OwnerID: "owner-1", Queue: jobqueue.QueueTypeStorageWriter}, + {JobID: "job-b", MessageID: idB, OwnerID: "owner-1", Queue: jobqueue.QueueTypeStorageWriter}, + }, + rescheduleErr: map[string]error{"job-b": errors.New("boom")}, + } + installFakeResultsClient(t, notFoundResultsClient()) // NotFound: replays remain needed + actions, _ := newFakeActionLog(nil) + h := newRescheduleTestHandlers(t, store, actions) + + tA := rescheduleTargetString("job-a", idA, jobqueue.QueueTypeStorageWriter, "owner-1") + tB := rescheduleTargetString("job-b", idB, jobqueue.QueueTypeStorageWriter, "owner-1") + c, _ := reschedulePostContext(url.Values{"target": {tA, tB}}) + h.rescheduleExecute(c) + require.Len(t, store.calls, 2) + + // Retry resubmits both targets; the previous success must not be re-executed. + c2, rec2 := reschedulePostContext(url.Values{"target": {tA, tB}, "retry": {"failed"}}) + h.rescheduleExecute(c2) + + require.Len(t, store.calls, 3, "only the still-failed target is re-attempted") + require.Equal(t, "job-b", store.calls[2].jobID) + body := rec2.Body.String() + require.Contains(t, body, "skipped") + require.Contains(t, body, "no matching failed archive row") + require.Contains(t, body, "boom") +} + +func TestRescheduleExecuteRecordsActionLogPerTarget(t *testing.T) { + idA, idB := rescheduleMsgID(15), rescheduleMsgID(16) + store := &rescheduleFakeStore{jobs: []jobqueue.ArchivedJob{ + {JobID: "job-a", MessageID: idA, OwnerID: "owner-1", Queue: jobqueue.QueueTypeTaskVerifier}, + {JobID: "job-b", MessageID: idB, OwnerID: "owner-2", Queue: jobqueue.QueueTypeTaskVerifier}, + }} + installFakeResultsClient(t, notFoundResultsClient()) + actions, drv := newFakeActionLog(nil) + h := newRescheduleTestHandlers(t, store, actions) + + form := url.Values{"target": { + rescheduleTargetString("job-a", idA, jobqueue.QueueTypeTaskVerifier, "owner-1"), + rescheduleTargetString("job-b", idB, jobqueue.QueueTypeTaskVerifier, "owner-2"), + }} + c, _ := reschedulePostContext(form) + h.rescheduleExecute(c) + + // Column order of ActionLog.Record's INSERT: actor, action, target, op, outcome, detail. + // Each target writes an intent row ("started") before mutating, then its outcome row. + execs := drv.recorded() + require.Len(t, execs, 4) + for i, id := range [][]byte{idA, idB} { + intent, outcome := execs[i*2], execs[i*2+1] + for _, args := range [][]driver.NamedValue{intent, outcome} { + require.Equal(t, "tester", args[0].Value) + require.Equal(t, "reschedule", args[1].Value) + require.Equal(t, formatMessageID(id), args[2].Value) + require.Equal(t, "", args[3].Value) + } + require.Equal(t, "started", intent[4].Value) + require.Contains(t, intent[5].Value, "restoring job") + require.Equal(t, "success", outcome[4].Value) + require.Contains(t, outcome[5].Value, "restored archive") + } +} + +// A direct POST to execute must not bypass the gate: an attested target is +// skipped even though no preview ran first. +func TestRescheduleExecuteDirectPostStillGated(t *testing.T) { + id := rescheduleMsgID(19) + store := &rescheduleFakeStore{jobs: []jobqueue.ArchivedJob{{ + JobID: "job-a", MessageID: id, OwnerID: "owner-1", Queue: jobqueue.QueueTypeTaskVerifier, + }}} + installFakeResultsClient(t, &fakeResultsClient{entries: []storageaccess.ResultEntry{ + {Present: true, CcvData: []byte{0x01}}, + }}) + actions, _ := newFakeActionLog(nil) + h := newRescheduleTestHandlers(t, store, actions) + + c, rec := reschedulePostContext(url.Values{"target": {rescheduleTargetString("job-a", id, jobqueue.QueueTypeTaskVerifier, "owner-1")}}) + h.rescheduleExecute(c) + + require.Zero(t, store.calls, "the attestation gate runs on every execution") + require.Len(t, store.jobs, 1, "the archive row is untouched") + require.Contains(t, rec.Body.String(), "skipped") + require.Contains(t, rec.Body.String(), "already attested — nothing to do") +} + +// The action-log write precedes the mutation: an unavailable database means the +// reschedule does not proceed, never an unaudited mutation. +func TestRescheduleExecuteUnloggableMutationDoesNotProceed(t *testing.T) { + id := rescheduleMsgID(17) + store := &rescheduleFakeStore{jobs: []jobqueue.ArchivedJob{{ + JobID: "job-a", MessageID: id, OwnerID: "owner-1", Queue: jobqueue.QueueTypeTaskVerifier, + }}} + installFakeResultsClient(t, notFoundResultsClient()) + actions, _ := newFakeActionLog(errors.New("disk full")) + h := newRescheduleTestHandlers(t, store, actions) + + c, rec := reschedulePostContext(url.Values{"target": {rescheduleTargetString("job-a", id, jobqueue.QueueTypeTaskVerifier, "owner-1")}}) + h.rescheduleExecute(c) + + require.Zero(t, store.calls, "an unloggable mutation must not proceed") + require.Len(t, store.jobs, 1, "the archive row is untouched") + body := rec.Body.String() + require.Contains(t, body, "not executed — action log unavailable") + require.Contains(t, body, "disk full") +} + +func TestRescheduleExecuteBadInput(t *testing.T) { + actions, _ := newFakeActionLog(nil) + h := newRescheduleTestHandlers(t, &rescheduleFakeStore{}, actions) + + c, rec := reschedulePostContext(url.Values{"target": {"x"}, "retry_duration": {"abc"}}) + h.rescheduleExecute(c) + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Contains(t, rec.Body.String(), "invalid retry_duration") + + c, rec = reschedulePostContext(url.Values{"retry_duration": {"1h"}}) + h.rescheduleExecute(c) + require.Equal(t, http.StatusBadRequest, rec.Code) + require.Contains(t, rec.Body.String(), "No targets selected") + + c, rec = reschedulePostContext(url.Values{"target": {"not-a-target"}}) + h.rescheduleExecute(c) + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, rec.Body.String(), "invalid target") +} diff --git a/verifier/pkg/admin/search.go b/verifier/pkg/admin/search.go new file mode 100644 index 000000000..f16b0e40c --- /dev/null +++ b/verifier/pkg/admin/search.go @@ -0,0 +1,44 @@ +package admin + +import ( + "encoding/hex" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "github.com/smartcontractkit/chainlink-ccv/cli/jobqueue" + "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/admin/views" +) + +// Search is the console entry point: find one or several message IDs in this +// verifier's failed-job archive. A failed lookup is never rendered as an empty result. +func (h *handlers) registerSearchRoutes(r *gin.Engine) { + r.GET("/search", h.searchPage) + r.POST("/search", h.searchResults) +} + +func (h *handlers) searchPage(c *gin.Context) { + h.render(c, http.StatusOK, views.SearchPage(h.csrfToken(c), nil, nil)) +} + +func (h *handlers) searchResults(c *gin.Context) { + messageIDs, err := jobqueue.ParseMessageIDs(strings.Fields(c.PostForm("message_ids"))) + if err != nil { + h.render(c, http.StatusBadRequest, views.SearchResults(views.SearchResultsVM{}, err.Error())) + return + } + if len(messageIDs) == 0 { + h.render(c, http.StatusOK, views.SearchResults(views.SearchResultsVM{}, "")) + return + } + + failed, err := h.stores.JobQueue().ListFailedFiltered(c.Request.Context(), nil, "", messageIDs, 0) + vm := views.SearchResultsVM{Jobs: failed} + if err != nil { + vm.UnreachableDetail = "archive lookup failed: " + err.Error() + } + h.render(c, http.StatusOK, views.SearchPage(h.csrfToken(c), &vm, messageIDs)) +} + +func formatMessageID(id []byte) string { return "0x" + hex.EncodeToString(id) } diff --git a/verifier/pkg/admin/server.go b/verifier/pkg/admin/server.go new file mode 100644 index 000000000..ab4282b75 --- /dev/null +++ b/verifier/pkg/admin/server.go @@ -0,0 +1,199 @@ +package admin + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "errors" + "fmt" + "io/fs" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + + "github.com/smartcontractkit/chainlink-ccv/integration/pkg/api/middleware" + "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/admin/views" + "github.com/smartcontractkit/chainlink-common/pkg/logger" +) + +const csrfCookieName = "ccv_admin_csrf" + +// Deps are the in-process dependencies the console needs from the verifier it runs +// beside: its application database (migrations already applied) and its secrets. +type Deps struct { + // DB is the verifier's application database. The action log is written here too. + DB *sqlx.DB + // Auth is the basic-auth credential from the verifier secrets file's [admin_ui] + // table; nil serves without basic auth (the loopback personal-tool default). + Auth *BasicAuth + // AggregatorAddress (host:port) enables attestation freshness checks; empty + // disables them and reschedule execution stays blocked on "unknown". + AggregatorAddress string +} + +// Server is the admin console HTTP server. +type Server struct { + cfg *Config + lggr logger.Logger + stores stores + actions *ActionLog + basicAuth *BasicAuth + router *gin.Engine + httpSrv *http.Server +} + +// NewServer builds the console over the verifier's own database. The access policy +// (non-loopback needs an identity source) is enforced here, where the secrets-derived +// credential is available. +func NewServer(cfg *Config, deps Deps, lggr logger.Logger) (*Server, error) { + if cfg == nil { + return nil, errors.New("config is required") + } + if deps.DB == nil { + return nil, errors.New("the verifier application database is required") + } + if err := ValidateAccessPolicy(cfg, deps.Auth); err != nil { + return nil, err + } + s := &Server{ + cfg: cfg, + lggr: logger.With(lggr, "component", "AdminConsole"), + stores: stores{db: deps.DB, lggr: lggr}, + actions: NewActionLog(deps.DB), + basicAuth: deps.Auth, + } + s.router = s.buildRouter(deps.AggregatorAddress) + return s, nil +} + +func (s *Server) buildRouter(aggregatorAddress string) *gin.Engine { + gin.SetMode(gin.ReleaseMode) + r := gin.New() + r.Use(middleware.GinLogger(s.lggr), middleware.SecureRecovery(s.lggr), s.securityHeaders, s.actorMiddleware, s.basicAuthMiddleware, s.csrfMiddleware) + + h := &handlers{cfg: s.cfg, lggr: s.lggr, stores: s.stores, actions: s.actions, aggregatorAddress: aggregatorAddress} + staticSub, err := fs.Sub(views.StaticFS, "static") + if err != nil { + s.lggr.Errorw("failed to mount static assets", "error", err) + } else { + r.StaticFS("/static", http.FS(staticSub)) + } + h.registerCoreRoutes(r) + h.registerSearchRoutes(r) + h.registerDetailRoutes(r) + h.registerRescheduleRoutes(r) + h.registerRecoveryRoutes(r) + return r +} + +// Run serves until ctx is canceled, then shuts down gracefully. +func (s *Server) Run(ctx context.Context) error { + s.httpSrv = &http.Server{ + Addr: s.cfg.ListenAddress, + Handler: s.router, + ReadHeaderTimeout: 10 * time.Second, + } + errCh := make(chan error, 1) + go func() { + s.lggr.Infow("admin console listening", "address", s.cfg.ListenAddress) + if err := s.httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + }() + select { + case err := <-errCh: + return err + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return s.httpSrv.Shutdown(shutdownCtx) + } +} + +// actorMiddleware resolves the per-request actor: the configured proxy header on shared +// hosting, or "local" on loopback. The action log trusts only this value. Basic auth +// overrides it: an authenticated username is verified by the console itself. +func (s *Server) actorMiddleware(c *gin.Context) { + actor := "local" + if header := s.cfg.Access.ActorHeader; header != "" { + if value := c.GetHeader(header); value != "" { + actor = value + } else { + actor = "unknown" + } + } + c.Set("actor", actor) + c.Next() +} + +// basicAuthMiddleware gates every route except /healthz when the verifier secrets file +// carries an [admin_ui] credential. Both comparisons are constant-time. The +// authenticated username becomes the actor (outranking the proxy header). +func (s *Server) basicAuthMiddleware(c *gin.Context) { + if s.basicAuth == nil || c.Request.URL.Path == "/healthz" { + c.Next() + return + } + user, pass, ok := c.Request.BasicAuth() + if !ok || + subtle.ConstantTimeCompare([]byte(user), []byte(s.basicAuth.Username)) != 1 || + subtle.ConstantTimeCompare([]byte(pass), []byte(s.basicAuth.Password)) != 1 { + c.Header("WWW-Authenticate", `Basic realm="ccv-admin"`) + c.AbortWithStatus(http.StatusUnauthorized) + return + } + c.Set("actor", user) + c.Next() +} + +// csrfMiddleware protects browser-originated mutations: every unsafe method must carry +// the per-browser token as a form field or header matching the cookie. +func (s *Server) csrfMiddleware(c *gin.Context) { + token := "" + if cookie, err := c.Cookie(csrfCookieName); err == nil { + token = cookie + } + if token == "" || len(token) > 128 { + token = newCSRFToken() + // Secure only over TLS or an https-forwarding proxy: the default + // loopback deployment is plain HTTP and must still receive the token. + secure := c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") + c.SetCookie(csrfCookieName, token, 0, "/", "", secure, true) + } + c.Set("csrfToken", token) + + switch c.Request.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + c.Next() + return + } + provided := c.PostForm("csrf_token") + if provided == "" { + provided = c.GetHeader("X-CSRF-Token") + } + if provided == "" || subtle.ConstantTimeCompare([]byte(provided), []byte(token)) != 1 { + c.AbortWithStatus(http.StatusForbidden) + return + } + c.Next() +} + +func (s *Server) securityHeaders(c *gin.Context) { + c.Header("X-Frame-Options", "DENY") + c.Header("X-Content-Type-Options", "nosniff") + c.Header("Referrer-Policy", "no-referrer") + c.Header("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'") + c.Next() +} + +func newCSRFToken() string { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + panic(fmt.Sprintf("failed to generate CSRF token: %v", err)) + } + return hex.EncodeToString(buf) +} diff --git a/verifier/pkg/admin/server_test.go b/verifier/pkg/admin/server_test.go new file mode 100644 index 000000000..4cbe269de --- /dev/null +++ b/verifier/pkg/admin/server_test.go @@ -0,0 +1,97 @@ +package admin + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" +) + +// newFakeSQLDB returns a *sqlx.DB backed by the fake driver: ExecContext is captured, +// anything else errors, so no real database is needed for server-construction tests. +func newFakeSQLDB(t *testing.T) *sqlx.DB { + t.Helper() + db, _ := newFakeActionLog(nil) + return db.ds +} + +func newTestServer(t *testing.T, cfgBody string, auth *BasicAuth) *Server { + t.Helper() + cfg, err := LoadConfig(writeConfig(t, cfgBody)) + require.NoError(t, err) + srv, err := NewServer(cfg, Deps{DB: newFakeSQLDB(t), Auth: auth}, logger.Test(t)) + require.NoError(t, err) + return srv +} + +func TestServerHealthz(t *testing.T) { + srv := newTestServer(t, "", nil) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + srv.router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) +} + +func TestServerRootRedirectsToSearch(t *testing.T) { + srv := newTestServer(t, "", nil) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + srv.router.ServeHTTP(rec, req) + require.Equal(t, http.StatusFound, rec.Code) + require.Equal(t, "/search", rec.Header().Get("Location")) +} + +func TestServerRequiresDatabase(t *testing.T) { + cfg, err := LoadConfig(writeConfig(t, "")) + require.NoError(t, err) + _, err = NewServer(cfg, Deps{}, logger.Test(t)) + require.ErrorContains(t, err, "database") +} + +func TestServerCSRFFlow(t *testing.T) { + srv := newTestServer(t, "", nil) + + // Unsafe method without a token: forbidden. + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/search", strings.NewReader("message_ids=0x00")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + srv.router.ServeHTTP(rec, req) + require.Equal(t, http.StatusForbidden, rec.Code) + + // A GET sets the cookie; echoing it as the form field lets the POST through. + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/search", nil) + srv.router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + var token string + for _, c := range rec.Result().Cookies() { + if c.Name == csrfCookieName { + token = c.Value + } + } + require.NotEmpty(t, token) + + form := url.Values{"csrf_token": {token}, "message_ids": {"0x0000000000000000000000000000000000000000000000000000000000000000"}} + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/search", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: csrfCookieName, Value: token}) + srv.router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, rec.Body.String(), "Lookup unavailable") // the fake driver answers no queries +} + +func TestServerActorResolution(t *testing.T) { + cfg, err := LoadConfig(writeConfig(t, `listen_address = "127.0.0.1:8105" +[access] +actor_header = "X-Remote-User" +`)) + require.NoError(t, err) + require.Equal(t, "X-Remote-User", cfg.Access.ActorHeader) +} diff --git a/verifier/pkg/admin/stores.go b/verifier/pkg/admin/stores.go new file mode 100644 index 000000000..e20cdb4af --- /dev/null +++ b/verifier/pkg/admin/stores.go @@ -0,0 +1,28 @@ +package admin + +import ( + "github.com/jmoiron/sqlx" + + "github.com/smartcontractkit/chainlink-ccv/cli/jobqueue" + recoverycli "github.com/smartcontractkit/chainlink-ccv/cli/recovery" + "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/chainstatus" + "github.com/smartcontractkit/chainlink-ccv/verifier/pkg/recovery" + "github.com/smartcontractkit/chainlink-common/pkg/logger" +) + +// stores bundles the read/write surfaces the console uses over the verifier's own +// application database. The database handle is owned by the caller (the verifier +// process), which has already applied the verifier migrations, admin action log +// included. +type stores struct { + db *sqlx.DB + lggr logger.Logger +} + +func (s stores) JobQueue() jobqueue.Store { return jobqueue.NewPostgresStore(s.db) } + +func (s stores) Recovery() recoverycli.Store { return recovery.NewStore(s.db) } + +func (s stores) ChainStatuses() *chainstatus.PostgresChainStatusStore { + return chainstatus.NewPostgresChainStatusStore(s.db, s.lggr) +} diff --git a/verifier/pkg/admin/views/actions.templ b/verifier/pkg/admin/views/actions.templ new file mode 100644 index 000000000..bb4ee51ef --- /dev/null +++ b/verifier/pkg/admin/views/actions.templ @@ -0,0 +1,50 @@ +package views + +import "time" + +// ActionVM is one action-log row as rendered. Kept free of the admin package's types +// so views never imports its caller. +type ActionVM struct { + Actor, Action, Target, OperationID, Outcome, Detail string + CreatedAt time.Time +} + +// ActionsPage lists the console's mutation history, newest first. +templ ActionsPage(actions []ActionVM) { + @Layout("Action log") { +

Action log

+

Every console mutation: actor, time, target, operation, and outcome.

+ if len(actions) == 0 { +

No actions recorded.

+ } else { +
+ + + + + + + + + + + + + + for _, a := range actions { + + + + + + + + + + } + +
Time (UTC)ActorActionTargetOperationOutcomeDetail
{ a.CreatedAt.UTC().Format(time.RFC3339) }{ a.Actor }{ a.Action }{ a.Target }{ a.OperationID }{ a.Outcome }{ a.Detail }
+
+ } + } +} diff --git a/verifier/pkg/admin/views/actions_templ.go b/verifier/pkg/admin/views/actions_templ.go new file mode 100644 index 000000000..da5118f0d --- /dev/null +++ b/verifier/pkg/admin/views/actions_templ.go @@ -0,0 +1,180 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package views + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "time" + +// ActionVM is one action-log row as rendered. Kept free of the admin package's types +// so views never imports its caller. +type ActionVM struct { + Actor, Action, Target, OperationID, Outcome, Detail string + CreatedAt time.Time +} + +// ActionsPage lists the console's mutation history, newest first. +func ActionsPage(actions []ActionVM) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Action log

Every console mutation: actor, time, target, operation, and outcome.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(actions) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

No actions recorded.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, a := range actions { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
Time (UTC)ActorActionTargetOperationOutcomeDetail
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(a.CreatedAt.UTC().Format(time.RFC3339)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `actions.templ`, Line: 36, Col: 52} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(a.Actor) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `actions.templ`, Line: 37, Col: 21} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(a.Action) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `actions.templ`, Line: 38, Col: 22} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(a.Target) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `actions.templ`, Line: 39, Col: 40} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(a.OperationID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `actions.templ`, Line: 40, Col: 45} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(a.Outcome) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `actions.templ`, Line: 41, Col: 23} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(a.Detail) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `actions.templ`, Line: 42, Col: 29} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) + templ_7745c5c3_Err = Layout("Action log").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/verifier/pkg/admin/views/detail.templ b/verifier/pkg/admin/views/detail.templ new file mode 100644 index 000000000..fd28ef942 --- /dev/null +++ b/verifier/pkg/admin/views/detail.templ @@ -0,0 +1,304 @@ +package views + +import ( + "fmt" + "time" + + "github.com/smartcontractkit/chainlink-ccv/cli/jobqueue" +) + +// DetailVM is the message-detail page model. UnreachableDetail non-empty means the +// verifier's database was not available and no lookups ran; the per-section Detail +// fields mark individual lookups that failed and are rendered as errors, never as +// empties. +type DetailVM struct { + MessageID string + TraceURL string + UnreachableDetail string + ArchiveDetail string + Failed []ArchivedJobVM + EventsDetail string + Events []DropEventVM + RetainedSince time.Time + Coverage string + SourceChain string + ChainDetail string + Chains []ChainStatusVM +} + +// ArchivedJobVM is one failed archive row plus its reschedule affordance. +type ArchivedJobVM struct { + Job jobqueue.ArchivedJob + RescheduleTarget string + ButtonLabel string +} + +// DropEventVM is one durable drop/incident event (R4 evidence). +type DropEventVM struct { + Kind string + Stage string + Reason string + OwnerID string + SourceChain string + SourceBlock string + TxHash string + IncidentID string + Observations string + FirstObserved time.Time + LastObserved time.Time + ExpiresAt time.Time +} + +// ChainStatusVM is the chain-status row for the message's source chain. +type ChainStatusVM struct { + ChainSelector string + VerifierID string + FinalizedHeight string + Disabled bool + UpdatedAt time.Time +} + +templ DetailPage(csrfToken string, vm DetailVM) { + @Layout("Message detail") { +

Message { vm.MessageID }

+ if vm.TraceURL != "" { +

+ Trace viewer +

+ } + if vm.UnreachableDetail != "" { +
+ Database unavailable: { vm.UnreachableDetail }. Nothing on this page was looked up — treat the + message's state as unknown, not absent. +
+ } else { + @detailVerdict(vm) + @detailArchive(csrfToken, vm) + @detailAttestation() + @detailEvidence(vm) + @detailChainStatus(vm) + } + } +} + +// detailVerdict renders the overall state: archived failures, an observed pre-admission +// drop, not found, or unknown because a lookup failed. +templ detailVerdict(vm DetailVM) { + if len(vm.Failed) > 0 { + + } else if len(vm.Events) > 0 { + + } else if vm.ArchiveDetail == "" && vm.EventsDetail == "" { +

Message not found: no archived failed jobs and no observed drop/incident events.

+ } else { + + } +} + +templ detailArchive(csrfToken string, vm DetailVM) { +

Failed archive rows

+ if vm.ArchiveDetail != "" { +
Archive lookup failed: { vm.ArchiveDetail }. Treat the archive as unknown.
+ } else if len(vm.Failed) == 0 { +

No archived failed jobs for this message.

+ } else { +
+ + + + + + + + + + + + + + + + + + + for _, job := range vm.Failed { + + + + + + + + + + + + + + + } + +
QueueOwnerJob IDFailureLast errorAttemptsCreatedArchivedArchive ageArchive expiresRetry deadline
{ string(job.Job.Queue) }{ job.Job.OwnerID }{ job.Job.JobID }{ job.Job.FailureCategory }{ job.Job.LastError }{ fmt.Sprint(job.Job.AttemptCount) }{ formatT(job.Job.CreatedAt) }{ formatTime(job.Job.ArchivedAt) }{ archiveAge(job.Job.ArchivedAt) }{ archiveExpiry(job.Job.ArchivedAt) }{ formatT(job.Job.RetryDeadline) } +
+ @CSRFField(csrfToken) + + +
+
+
+
+ Archive retention & what reschedule does + Archive rows are retained for 30 days from archiving and swept roughly every 4 hours. + Rescheduling restores the saved payload to the active queue — neither action re-checks + source-chain finality. +
+ } +} + +// detailAttestation is a pointer, not a section: the freshness check lives in the +// reschedule flow, so the detail page stays quiet about it. +templ detailAttestation() { +
+ Attestation freshness is not checked on this page + The aggregator attestation check runs at reschedule-preview time, before any + job is restored. +
+} + +templ detailEvidence(vm DetailVM) { +

Drop & incident evidence

+ if vm.EventsDetail != "" { +
Event lookup failed: { vm.EventsDetail }. Treat the event history as unknown.
+ } else { + if len(vm.Events) == 0 { +

No drop or incident events observed for this message.

+ } else { +
+ + + + + + + + + + + + + + + + + + + for _, e := range vm.Events { + + + + + + + + + + + + + + + } + +
KindStageReasonOwnerSource chainSource blockTx hashIncidentFirst observedLast observedObservationsEvidence expires
{ e.Kind }{ e.Stage }{ e.Reason }{ e.OwnerID }{ e.SourceChain }{ e.SourceBlock }{ e.TxHash }{ e.IncidentID }{ formatT(e.FirstObserved) }{ formatT(e.LastObserved) }{ e.Observations }{ formatT(e.ExpiresAt) }
+
+ } +
+ Evidence coverage & caveats + Event history retained since { formatT(vm.RetainedSince) } (30-day retention). { vm.Coverage } + An empty result over an incomplete history is unknown, not "nothing happened". +
+ } +} + +templ detailChainStatus(vm DetailVM) { +

Source chain status

+ if vm.ChainDetail != "" { +
Chain status lookup failed: { vm.ChainDetail }.
+ } else if vm.SourceChain == "" { +

Source chain unknown — no archive rows or observed events name it.

+ } else if len(vm.Chains) == 0 { +

No chain-status rows for source chain { vm.SourceChain }.

+ } else { +
+ + + + + + + + + + + + for _, row := range vm.Chains { + + + + + + + + } + +
Source chainVerifierFinalized heightReader stateUpdated
{ row.ChainSelector }{ row.VerifierID }{ row.FinalizedHeight } + if row.Disabled { + disabled + } else { + enabled + } + { formatT(row.UpdatedAt) }
+
+ if anyChainDisabled(vm.Chains) { + + } + } +} + +func anyChainDisabled(rows []ChainStatusVM) bool { + for _, r := range rows { + if r.Disabled { + return true + } + } + return false +} + +func formatT(t time.Time) string { return t.UTC().Format(time.RFC3339) } + +func archiveAge(archivedAt *time.Time) string { + if archivedAt == nil { + return "—" + } + d := time.Since(*archivedAt) + if d < 0 { + d = 0 + } + switch { + case d >= 48*time.Hour: + return fmt.Sprintf("%dd", int(d.Hours())/24) + case d >= time.Hour: + return fmt.Sprintf("%dh", int(d.Hours())) + default: + return fmt.Sprintf("%dm", int(d.Minutes())) + } +} diff --git a/verifier/pkg/admin/views/detail_templ.go b/verifier/pkg/admin/views/detail_templ.go new file mode 100644 index 000000000..9abdd8808 --- /dev/null +++ b/verifier/pkg/admin/views/detail_templ.go @@ -0,0 +1,989 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package views + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + "time" + + "github.com/smartcontractkit/chainlink-ccv/cli/jobqueue" +) + +// DetailVM is the message-detail page model. UnreachableDetail non-empty means the +// verifier's database was not available and no lookups ran; the per-section Detail +// fields mark individual lookups that failed and are rendered as errors, never as +// empties. +type DetailVM struct { + MessageID string + TraceURL string + UnreachableDetail string + ArchiveDetail string + Failed []ArchivedJobVM + EventsDetail string + Events []DropEventVM + RetainedSince time.Time + Coverage string + SourceChain string + ChainDetail string + Chains []ChainStatusVM +} + +// ArchivedJobVM is one failed archive row plus its reschedule affordance. +type ArchivedJobVM struct { + Job jobqueue.ArchivedJob + RescheduleTarget string + ButtonLabel string +} + +// DropEventVM is one durable drop/incident event (R4 evidence). +type DropEventVM struct { + Kind string + Stage string + Reason string + OwnerID string + SourceChain string + SourceBlock string + TxHash string + IncidentID string + Observations string + FirstObserved time.Time + LastObserved time.Time + ExpiresAt time.Time +} + +// ChainStatusVM is the chain-status row for the message's source chain. +type ChainStatusVM struct { + ChainSelector string + VerifierID string + FinalizedHeight string + Disabled bool + UpdatedAt time.Time +} + +func DetailPage(csrfToken string, vm DetailVM) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Message ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(vm.MessageID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 63, Col: 46} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if vm.TraceURL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

Trace viewer

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if vm.UnreachableDetail != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
Database unavailable: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(vm.UnreachableDetail) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 71, Col: 48} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, ". Nothing on this page was looked up — treat the message's state as unknown, not absent.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = detailVerdict(vm).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = detailArchive(csrfToken, vm).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = detailAttestation().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = detailEvidence(vm).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = detailChainStatus(vm).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) + templ_7745c5c3_Err = Layout("Message detail").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// detailVerdict renders the overall state: archived failures, an observed pre-admission +// drop, not found, or unknown because a lookup failed. +func detailVerdict(vm DetailVM) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var6 := templ.GetChildren(ctx) + if templ_7745c5c3_Var6 == nil { + templ_7745c5c3_Var6 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if len(vm.Failed) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint(len(vm.Failed))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 89, Col: 31} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " archived failed job(s) for this message — reschedule below.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if len(vm.Events) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
No archived failed jobs, but the source reader observed drop/incident events for this message: it was dropped before queue admission, so there is nothing to reschedule. Recovery means a bounded source re-read from the source recovery page.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if vm.ArchiveDetail == "" && vm.EventsDetail == "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

Message not found: no archived failed jobs and no observed drop/incident events.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
Some lookups failed, so the message's state is unknown — see the errors below.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +func detailArchive(csrfToken string, vm DetailVM) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var8 := templ.GetChildren(ctx) + if templ_7745c5c3_Var8 == nil { + templ_7745c5c3_Var8 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "

Failed archive rows

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if vm.ArchiveDetail != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
Archive lookup failed: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(vm.ArchiveDetail) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 107, Col: 62} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, ". Treat the archive as unknown.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if len(vm.Failed) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "

No archived failed jobs for this message.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, job := range vm.Failed { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
QueueOwnerJob IDFailureLast errorAttemptsCreatedArchivedArchive ageArchive expiresRetry deadline
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(string(job.Job.Queue)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 132, Col: 34} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(job.Job.OwnerID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 133, Col: 34} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(job.Job.JobID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 134, Col: 32} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(job.Job.FailureCategory) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 135, Col: 36} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(job.Job.LastError) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 136, Col: 30} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint(job.Job.AttemptCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 137, Col: 45} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(formatT(job.Job.CreatedAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 138, Col: 39} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(formatTime(job.Job.ArchivedAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 139, Col: 43} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(archiveAge(job.Job.ArchivedAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 140, Col: 43} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(archiveExpiry(job.Job.ArchivedAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 141, Col: 46} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(formatT(job.Job.RetryDeadline)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 142, Col: 43} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = CSRFField(csrfToken).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
Archive retention & what reschedule does Archive rows are retained for 30 days from archiving and swept roughly every 4 hours. Rescheduling restores the saved payload to the active queue — neither action re-checks source-chain finality.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +// detailAttestation is a pointer, not a section: the freshness check lives in the +// reschedule flow, so the detail page stays quiet about it. +func detailAttestation() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var23 := templ.GetChildren(ctx) + if templ_7745c5c3_Var23 == nil { + templ_7745c5c3_Var23 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
Attestation freshness is not checked on this page The aggregator attestation check runs at reschedule-preview time, before any job is restored.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func detailEvidence(vm DetailVM) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var24 := templ.GetChildren(ctx) + if templ_7745c5c3_Var24 == nil { + templ_7745c5c3_Var24 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "

Drop & incident evidence

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if vm.EventsDetail != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
Event lookup failed: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(vm.EventsDetail) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 177, Col: 59} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ". Treat the event history as unknown.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + if len(vm.Events) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "

No drop or incident events observed for this message.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, e := range vm.Events { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
KindStageReasonOwnerSource chainSource blockTx hashIncidentFirst observedLast observedObservationsEvidence expires
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var26 string + templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(e.Kind) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 203, Col: 20} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var27 string + templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(e.Stage) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 204, Col: 21} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var28 string + templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(e.Reason) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 205, Col: 22} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var29 string + templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(e.OwnerID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 206, Col: 29} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var30 string + templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(e.SourceChain) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 207, Col: 27} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var31 string + templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(e.SourceBlock) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 208, Col: 27} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var32 string + templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(e.TxHash) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 209, Col: 22} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var33 string + templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(e.IncidentID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 210, Col: 26} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var34 string + templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(formatT(e.FirstObserved)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 211, Col: 38} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(formatT(e.LastObserved)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 212, Col: 37} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var36 string + templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(e.Observations) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 213, Col: 28} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var37 string + templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(formatT(e.ExpiresAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 214, Col: 34} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "
Evidence coverage & caveats Event history retained since ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var38 string + templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(formatT(vm.RetainedSince)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 223, Col: 59} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " (30-day retention). ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var39 string + templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(vm.Coverage) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 223, Col: 95} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " An empty result over an incomplete history is unknown, not \"nothing happened\".
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +func detailChainStatus(vm DetailVM) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var40 := templ.GetChildren(ctx) + if templ_7745c5c3_Var40 == nil { + templ_7745c5c3_Var40 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "

Source chain status

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if vm.ChainDetail != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "
Chain status lookup failed: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var41 string + templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(vm.ChainDetail) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 232, Col: 65} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, ".
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if vm.SourceChain == "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "

Source chain unknown — no archive rows or observed events name it.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if len(vm.Chains) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "

No chain-status rows for source chain ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var42 string + templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(vm.SourceChain) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 236, Col: 63} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, ".

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, row := range vm.Chains { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "
Source chainVerifierFinalized heightReader stateUpdated
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var43 string + templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(row.ChainSelector) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 252, Col: 30} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var44 string + templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(row.VerifierID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 253, Col: 33} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var45 string + templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(row.FinalizedHeight) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 254, Col: 32} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if row.Disabled { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "disabled") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "enabled") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var46 string + templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(formatT(row.UpdatedAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `detail.templ`, Line: 262, Col: 35} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if anyChainDisabled(vm.Chains) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "
The reader for this source chain is disabled: recovery requires the investigated reset-reader action on the source recovery page — ordinary replay will not re-enable it.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } + return nil + }) +} + +func anyChainDisabled(rows []ChainStatusVM) bool { + for _, r := range rows { + if r.Disabled { + return true + } + } + return false +} + +func formatT(t time.Time) string { return t.UTC().Format(time.RFC3339) } + +func archiveAge(archivedAt *time.Time) string { + if archivedAt == nil { + return "—" + } + d := time.Since(*archivedAt) + if d < 0 { + d = 0 + } + switch { + case d >= 48*time.Hour: + return fmt.Sprintf("%dd", int(d.Hours())/24) + case d >= time.Hour: + return fmt.Sprintf("%dh", int(d.Hours())) + default: + return fmt.Sprintf("%dm", int(d.Minutes())) + } +} + +var _ = templruntime.GeneratedTemplate diff --git a/verifier/pkg/admin/views/layout.templ b/verifier/pkg/admin/views/layout.templ new file mode 100644 index 000000000..043f20b9a --- /dev/null +++ b/verifier/pkg/admin/views/layout.templ @@ -0,0 +1,87 @@ +package views + +// Layout is the shared page frame: themed header nav, content container, footer. +templ Layout(title string) { + + + + + + { title } — CCV admin + + + + + +
+
+ + @logoMark() + CCV Admin + + +
+
+
+ { children... } +
+
+ CCV admin console — actions are previewed and audited. Verify targets before acting. +
+ + +} + +// logoMark is the Chainlink mark: hexagon outline around the perspective cube, drawn +// with strokes only so it reads at small sizes (light-blue hexagon, white cube on navy). +templ logoMark() { + +} + +templ navLink(href, label, title string) { + if navSection(title) == href { + { label } + } else { + { label } + } +} + +// navSection maps a page title to its nav item so sub-pages highlight their section. +func navSection(title string) string { + switch title { + case "Message search", "Message detail", "Reschedule preview", "Reschedule results": + return "/search" + case "Source recovery": + return "/recovery" + case "Action log": + return "/actions" + } + return "" +} + +templ ErrorPage(title, detail string) { + @Layout(title) { +

{ title }

+
{ detail }
+ } +} + +templ PlaceholderPage(title, detail string) { + @Layout(title) { +

{ title }

+ + } +} + +// CSRFField is the hidden input every mutation form must include. +templ CSRFField(token string) { + +} diff --git a/verifier/pkg/admin/views/layout_templ.go b/verifier/pkg/admin/views/layout_templ.go new file mode 100644 index 000000000..5114bf1ec --- /dev/null +++ b/verifier/pkg/admin/views/layout_templ.go @@ -0,0 +1,407 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package views + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +// Layout is the shared page frame: themed header nav, content container, footer. +func Layout(title string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `layout.templ`, Line: 10, Col: 17} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " — CCV admin
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = logoMark().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "CCV Admin
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
CCV admin console — actions are previewed and audited. Verify targets before acting.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// logoMark is the Chainlink mark: hexagon outline around the perspective cube, drawn +// with strokes only so it reads at small sizes (light-blue hexagon, white cube on navy). +func logoMark() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func navLink(href, label, title string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var4 := templ.GetChildren(ctx) + if templ_7745c5c3_Var4 == nil { + templ_7745c5c3_Var4 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if navSection(title) == href { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(label) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `layout.templ`, Line: 51, Col: 56} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(label) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `layout.templ`, Line: 53, Col: 41} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +// navSection maps a page title to its nav item so sub-pages highlight their section. +func navSection(title string) string { + switch title { + case "Message search", "Message detail", "Reschedule preview", "Reschedule results": + return "/search" + case "Source recovery": + return "/recovery" + case "Action log": + return "/actions" + } + return "" +} + +func ErrorPage(title, detail string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var9 := templ.GetChildren(ctx) + if templ_7745c5c3_Var9 == nil { + templ_7745c5c3_Var9 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var10 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `layout.templ`, Line: 72, Col: 13} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(detail) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `layout.templ`, Line: 73, Col: 29} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = Layout(title).Render(templ.WithChildren(ctx, templ_7745c5c3_Var10), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func PlaceholderPage(title, detail string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var13 := templ.GetChildren(ctx) + if templ_7745c5c3_Var13 == nil { + templ_7745c5c3_Var13 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var14 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `layout.templ`, Line: 79, Col: 13} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(detail) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `layout.templ`, Line: 80, Col: 30} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = Layout(title).Render(templ.WithChildren(ctx, templ_7745c5c3_Var14), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// CSRFField is the hidden input every mutation form must include. +func CSRFField(token string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var17 := templ.GetChildren(ctx) + if templ_7745c5c3_Var17 == nil { + templ_7745c5c3_Var17 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/verifier/pkg/admin/views/recovery.templ b/verifier/pkg/admin/views/recovery.templ new file mode 100644 index 000000000..f9ec5fdff --- /dev/null +++ b/verifier/pkg/admin/views/recovery.templ @@ -0,0 +1,486 @@ +package views + +import "time" + +// RecoveryPreviewVM is the capability view for the selected action. +type RecoveryPreviewVM struct { + Mode string + SubmitEnabled bool + Finding RecoveryPreviewNodeVM +} + +// RecoveryPreviewNodeVM is the capability finding. Error non-empty means the verifier +// could not be inspected at all; the finding must not be treated as approval. +type RecoveryPreviewNodeVM struct { + Error string + Registered bool + ReaderDisabled bool + LatestHead string + HeadStale bool + FinalizedHeight string + ActiveResetID string + RangeText string + Warnings []string + Allowed bool + BlockedReason string +} + +// RecoverySubmitVM is the outcome of one submission attempt. +type RecoverySubmitVM struct { + Error string + OperationID string + State string + ToBlock string +} + +// RecoveryOperationVM is one durable recovery operation as rendered in a table row. +type RecoveryOperationVM struct { + ID, Mode, State string + RangeFrom, RangeTo string + Progress, Counters string + Actor, Note, LastError string + ResetApplied bool + CanCancel, CanResume bool + RowError string + UpdatedAt time.Time +} + +// RecoveryOperationsVM is the full operations fragment. InFlight drives 5s polling. +type RecoveryOperationsVM struct { + Error string + Ops []RecoveryOperationVM + InFlight bool +} + +// RecoveryEvidenceVM is the retained R4 evidence page plus reader state. +type RecoveryEvidenceVM struct { + Error string + Coverage string + RetainedSince string + NextCursor string + Readers []RecoveryReaderVM + Events []RecoveryEventVM +} + +// RecoveryReaderVM is one ccv_recovery_readers row as evidence context. +type RecoveryReaderVM struct { + NodeID, Disabled, LatestBlock, HeadObservedAt string + LastSeenAt, HistoryStartedAt string + ActiveResetID, AuditFailures string +} + +// RecoveryEventVM is one retained drop/incident/reset event. Empty strings render as —. +type RecoveryEventVM struct { + Kind, Stage, Reason string + SourceBlock, MessageID string + TxHash, BlockHash, IncidentID string + Observations string + FirstObserved, LastObserved, Expires string +} + +// RecoveryPage is the source-range recovery console: one form for both actions, with +// evidence and durable operations below. Ordinary replay never clears a finality block. +templ RecoveryPage(csrfToken string) { + @Layout("Source recovery") { +

Source-range recovery

+

Re-read a source block range through this verifier's durable recovery machinery — every action is previewed before submission.

+
+ How recovery works: replay vs reset-reader, and bounds +

+ replay re-reads an inclusive source block range and re-runs admission and + verification for the events found there. It never rewinds the normal reader checkpoint and never + re-enables a disabled reader. +

+

+ reset-reader is the investigated recovery action for a finality-blocked + (disabled) reader: it records your operator identity and boundary evidence, re-initializes the + finality checker at from-block − 1, re-enables the reader, and recovers the range. + Submit it only after establishing the canonical chain and a known-good boundary. It requires a + disabled reader; an enabled reader takes replay instead. +

+

+ Bounds: at most 100 blocks and 1,000 events per chunk; recovery pauses while an owner has + 10,000 active verification jobs. A range covers every lane on the source chain. Operations are + durable in the verifier database and survive reloads and restarts. +

+
+
+
+ @CSRFField(csrfToken) +

+ + +

+

+ + +
+ Omitting to-block captures the reader's advertised head at submission; the target never follows later chain progress. +

+
+ Action + +
+ +
+

+ +

+

+ +
+ + Leave empty for a fresh request. After a disconnected submission, reuse the shown request + ID: a verifier that already accepted it returns its original operation. + +

+ +
+
+
+
+

Evidence

+

Retained drops, incidents and reader resets for the chosen owner/chain — load them before choosing a range.

+
+ What the evidence can and cannot tell you + A detected finality mismatch marks where detection happened — it is evidence, not automatically + the earliest affected block; scope the range from canonical-chain investigation. +
+ +
+

Operations

+

+ Read fresh from the verifier's database on every render; the list polls while work is in flight. +

+
+ + + +
+
+ } +} + +// RecoveryPreviewError renders a rejected preview request (bad form input). +templ RecoveryPreviewError(detail string) { +
{ detail }
+} + +// RecoveryPreview is the capability fragment; it carries the only submit button, so a +// blocked action (e.g. replay against a disabled reader) has no enabled submit path. +templ RecoveryPreview(vm RecoveryPreviewVM) { +

Capability check — { vm.Mode }

+ if vm.Finding.Error != "" { +
Could not inspect the verifier: { vm.Finding.Error }. Treat its capability as unknown.
+ } else { +
    +
  • + Reader for this owner/chain: + if vm.Finding.Registered { + registered + } else { + not registered — submission will be rejected + } +
  • +
  • + Reader state: + if vm.Finding.ReaderDisabled { + disabled (finality-blocked) + } else { + enabled + } +
  • +
  • + Latest observed head: { vm.Finding.LatestHead } + if vm.Finding.HeadStale { + (stale — older than one minute) + } +
  • +
  • Current finalized height: { vm.Finding.FinalizedHeight }
  • + if vm.Finding.ActiveResetID != "" { +
  • Active reset holding normal polling: { vm.Finding.ActiveResetID }
  • + } +
  • { vm.Finding.RangeText }
  • +
+ for _, w := range vm.Finding.Warnings { + + } + if vm.Finding.Allowed { +

{ vm.Mode } can be submitted.

+ } else { +
{ vm.Finding.BlockedReason }
+ } + } + if vm.SubmitEnabled { + + } else { + + } +
+ The capability view is a snapshot + Re-run the preview after changing any input. The submit path re-checks every finding on the + server before touching the database. +
+} + +// RecoverySubmitError renders a rejected submission (validation failure). +templ RecoverySubmitError(detail string) { +
{ detail }
+} + +// RecoverySubmitResult renders the outcome of one submission round. +templ RecoverySubmitResult(result RecoverySubmitVM, requestID string) { +

Submission result

+

+ Request ID: { requestID } — reuse it only to complete a disconnected + submission; do not resubmit after a success. +

+ if result.Error != "" { +
{ result.Error }
+ } else { +

+ Operation { result.OperationID } — state { result.State }, target to-block + { result.ToBlock }. Track it under Operations below. +

+ } +} + +// RecoveryOperations is the operations fragment; it self-polls every 5s while any +// operation is accepted or running. +templ RecoveryOperations(vm RecoveryOperationsVM, csrfToken string) { +
+ if vm.Error != "" { +
Operations unavailable: { vm.Error }. Treat the operation state as unknown.
+ } else if len(vm.Ops) == 0 { +

No recovery operations recorded for this filter.

+ } else { +
+ + + + + + + + + + + + + + + + for _, op := range vm.Ops { + @RecoveryOperationRow(op, csrfToken) + } + +
OperationModeStateRangeProgressCountersReset appliedUpdated (UTC)
+
+ } +
+} + +// RecoveryOperationRow renders one operation; cancel/resume swap this row in place. +templ RecoveryOperationRow(op RecoveryOperationVM, csrfToken string) { + if op.RowError != "" { + + +
+ { op.RowError } Refresh the operations list to see the current state. +
+ + + } else { + + + { op.ID } +
+ actor { op.Actor } + if op.Note != "" { +
+ { op.Note } + } + + { op.Mode } + + { op.State } + if op.LastError != "" { +
+ { op.LastError } + } + + { op.RangeFrom }–{ op.RangeTo } + { op.Progress } + { op.Counters } + + if op.ResetApplied { + yes + } else { + no + } + + { op.UpdatedAt.UTC().Format(time.RFC3339) } + + if op.CanCancel { +
+ @CSRFField(csrfToken) + +
+ } + if op.CanResume { +
+ @CSRFField(csrfToken) + +
+ } + + + } +} + +// RecoveryEvidence is the R4 evidence fragment. +templ RecoveryEvidence(vm RecoveryEvidenceVM) { + if vm.Error != "" { +
Evidence unavailable: { vm.Error }. Treat the evidence as unknown, not as "no incidents".
+ } else { + if len(vm.Readers) > 0 { +
+ + + + + + + + + + + + + + + for _, r := range vm.Readers { + + + + + + + + + + + } + +
Reader nodeDisabledLatest headHead observedLast seenHistory sinceActive resetAudit failures
{ r.NodeID }{ r.Disabled }{ r.LatestBlock }{ r.HeadObservedAt }{ r.LastSeenAt }{ r.HistoryStartedAt }{ r.ActiveResetID }{ r.AuditFailures }
+
+ } + + if len(vm.Events) == 0 { +

+ + No retained events for this filter. Absence of evidence never proves there was no affected + traffic — disabled intervals, downtime, audit failures and expired history leave gaps. You may + still scope and submit a manual range from canonical-chain investigation. + +

+ } else { +
+ + + + + + + + + + + + + + + + + for _, e := range vm.Events { + + + + + + + + + + + + + } + +
KindReasonStageSource blockMessage IDTx hashBlock hashIncidentObservedExpires (UTC)
{ e.Kind }{ e.Reason }{ e.Stage }{ e.SourceBlock }{ e.MessageID }{ e.TxHash }{ e.BlockHash }{ e.IncidentID } + + ×{ e.Observations }
+ { e.FirstObserved }
+ { e.LastObserved } +
+
{ e.Expires }
+
+ if vm.NextCursor != "" { + + } + } + } +} diff --git a/verifier/pkg/admin/views/recovery_templ.go b/verifier/pkg/admin/views/recovery_templ.go new file mode 100644 index 000000000..644c1a938 --- /dev/null +++ b/verifier/pkg/admin/views/recovery_templ.go @@ -0,0 +1,1358 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package views + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "time" + +// RecoveryPreviewVM is the capability view for the selected action. +type RecoveryPreviewVM struct { + Mode string + SubmitEnabled bool + Finding RecoveryPreviewNodeVM +} + +// RecoveryPreviewNodeVM is the capability finding. Error non-empty means the verifier +// could not be inspected at all; the finding must not be treated as approval. +type RecoveryPreviewNodeVM struct { + Error string + Registered bool + ReaderDisabled bool + LatestHead string + HeadStale bool + FinalizedHeight string + ActiveResetID string + RangeText string + Warnings []string + Allowed bool + BlockedReason string +} + +// RecoverySubmitVM is the outcome of one submission attempt. +type RecoverySubmitVM struct { + Error string + OperationID string + State string + ToBlock string +} + +// RecoveryOperationVM is one durable recovery operation as rendered in a table row. +type RecoveryOperationVM struct { + ID, Mode, State string + RangeFrom, RangeTo string + Progress, Counters string + Actor, Note, LastError string + ResetApplied bool + CanCancel, CanResume bool + RowError string + UpdatedAt time.Time +} + +// RecoveryOperationsVM is the full operations fragment. InFlight drives 5s polling. +type RecoveryOperationsVM struct { + Error string + Ops []RecoveryOperationVM + InFlight bool +} + +// RecoveryEvidenceVM is the retained R4 evidence page plus reader state. +type RecoveryEvidenceVM struct { + Error string + Coverage string + RetainedSince string + NextCursor string + Readers []RecoveryReaderVM + Events []RecoveryEventVM +} + +// RecoveryReaderVM is one ccv_recovery_readers row as evidence context. +type RecoveryReaderVM struct { + NodeID, Disabled, LatestBlock, HeadObservedAt string + LastSeenAt, HistoryStartedAt string + ActiveResetID, AuditFailures string +} + +// RecoveryEventVM is one retained drop/incident/reset event. Empty strings render as —. +type RecoveryEventVM struct { + Kind, Stage, Reason string + SourceBlock, MessageID string + TxHash, BlockHash, IncidentID string + Observations string + FirstObserved, LastObserved, Expires string +} + +// RecoveryPage is the source-range recovery console: one form for both actions, with +// evidence and durable operations below. Ordinary replay never clears a finality block. +func RecoveryPage(csrfToken string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Source-range recovery

Re-read a source block range through this verifier's durable recovery machinery — every action is previewed before submission.

How recovery works: replay vs reset-reader, and bounds

replay re-reads an inclusive source block range and re-runs admission and verification for the events found there. It never rewinds the normal reader checkpoint and never re-enables a disabled reader.

reset-reader is the investigated recovery action for a finality-blocked (disabled) reader: it records your operator identity and boundary evidence, re-initializes the finality checker at from-block − 1, re-enables the reader, and recovers the range. Submit it only after establishing the canonical chain and a known-good boundary. It requires a disabled reader; an enabled reader takes replay instead.

Bounds: at most 100 blocks and 1,000 events per chunk; recovery pauses while an owner has 10,000 active verification jobs. A range covers every lane on the source chain. Operations are durable in the verifier database and survive reloads and restarts.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = CSRFField(csrfToken).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "


Omitting to-block captures the reader's advertised head at submission; the target never follows later chain progress.

Action


Leave empty for a fresh request. After a disconnected submission, reuse the shown request ID: a verifier that already accepted it returns its original operation.

Evidence

Retained drops, incidents and reader resets for the chosen owner/chain — load them before choosing a range.

What the evidence can and cannot tell you A detected finality mismatch marks where detection happened — it is evidence, not automatically the earliest affected block; scope the range from canonical-chain investigation.

Operations

Read fresh from the verifier's database on every render; the list polls while work is in flight.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = Layout("Source recovery").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// RecoveryPreviewError renders a rejected preview request (bad form input). +func RecoveryPreviewError(detail string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(detail) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 181, Col: 28} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// RecoveryPreview is the capability fragment; it carries the only submit button, so a +// blocked action (e.g. replay against a disabled reader) has no enabled submit path. +func RecoveryPreview(vm RecoveryPreviewVM) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var5 := templ.GetChildren(ctx) + if templ_7745c5c3_Var5 == nil { + templ_7745c5c3_Var5 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

Capability check — ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(vm.Mode) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 187, Col: 35} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if vm.Finding.Error != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
Could not inspect the verifier: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(vm.Finding.Error) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 189, Col: 71} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, ". Treat its capability as unknown.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
  • Reader for this owner/chain: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if vm.Finding.Registered { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "registered") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "not registered — submission will be rejected") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
  • Reader state: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if vm.Finding.ReaderDisabled { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "disabled (finality-blocked)") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "enabled") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
  • Latest observed head: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(vm.Finding.LatestHead) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 209, Col: 49} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if vm.Finding.HeadStale { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "(stale — older than one minute)") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
  • Current finalized height: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(vm.Finding.FinalizedHeight) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 214, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if vm.Finding.ActiveResetID != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
  • Active reset holding normal polling: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(vm.Finding.ActiveResetID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 216, Col: 89} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(vm.Finding.RangeText) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 218, Col: 29} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, w := range vm.Finding.Warnings { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(w) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 221, Col: 26} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if vm.Finding.Allowed { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(vm.Mode) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 224, Col: 35} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " can be submitted.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(vm.Finding.BlockedReason) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 226, Col: 48} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } + if vm.SubmitEnabled { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
The capability view is a snapshot Re-run the preview after changing any input. The submit path re-checks every finding on the server before touching the database.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// RecoverySubmitError renders a rejected submission (validation failure). +func RecoverySubmitError(detail string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var16 := templ.GetChildren(ctx) + if templ_7745c5c3_Var16 == nil { + templ_7745c5c3_Var16 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(detail) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 245, Col: 28} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// RecoverySubmitResult renders the outcome of one submission round. +func RecoverySubmitResult(result RecoverySubmitVM, requestID string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var18 := templ.GetChildren(ctx) + if templ_7745c5c3_Var18 == nil { + templ_7745c5c3_Var18 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "

Submission result

Request ID: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(requestID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 252, Col: 43} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " — reuse it only to complete a disconnected submission; do not resubmit after a success.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if result.Error != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(result.Error) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 256, Col: 35} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "

Operation ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(result.OperationID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 259, Col: 51} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " — state ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(result.State) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 259, Col: 85} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ", target to-block ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(result.ToBlock) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 260, Col: 19} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, ". Track it under Operations below.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +// RecoveryOperations is the operations fragment; it self-polls every 5s while any +// operation is accepted or running. +func RecoveryOperations(vm RecoveryOperationsVM, csrfToken string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var24 := templ.GetChildren(ctx) + if templ_7745c5c3_Var24 == nil { + templ_7745c5c3_Var24 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if vm.Error != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
Operations unavailable: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(vm.Error) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 278, Col: 56} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ". Treat the operation state as unknown.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if len(vm.Ops) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "

No recovery operations recorded for this filter.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, op := range vm.Ops { + templ_7745c5c3_Err = RecoveryOperationRow(op, csrfToken).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
OperationModeStateRangeProgressCountersReset appliedUpdated (UTC)
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// RecoveryOperationRow renders one operation; cancel/resume swap this row in place. +func RecoveryOperationRow(op RecoveryOperationVM, csrfToken string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var26 := templ.GetChildren(ctx) + if templ_7745c5c3_Var26 == nil { + templ_7745c5c3_Var26 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if op.RowError != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var27 string + templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(op.RowError) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 314, Col: 18} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " Refresh the operations list to see the current state.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var28 string + templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(op.ID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 321, Col: 29} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
actor ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var29 string + templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(op.Actor) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 323, Col: 27} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if op.Note != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var30 string + templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(op.Note) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 326, Col: 21} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var31 string + templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(op.Mode) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 329, Col: 16} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var32 string + templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(op.State) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 331, Col: 14} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if op.LastError != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var33 string + templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(op.LastError) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 334, Col: 52} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var34 string + templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(op.RangeFrom) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 337, Col: 21} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "–") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(op.RangeTo) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 337, Col: 38} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var36 string + templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(op.Progress) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 338, Col: 20} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var37 string + templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(op.Counters) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 339, Col: 27} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if op.ResetApplied { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "yes") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "no") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var38 string + templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(op.UpdatedAt.UTC().Format(time.RFC3339)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 347, Col: 55} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if op.CanCancel { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = CSRFField(csrfToken).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if op.CanResume { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = CSRFField(csrfToken).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +// RecoveryEvidence is the R4 evidence fragment. +func RecoveryEvidence(vm RecoveryEvidenceVM) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var41 := templ.GetChildren(ctx) + if templ_7745c5c3_Var41 == nil { + templ_7745c5c3_Var41 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if vm.Error != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "
Evidence unavailable: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var42 string + templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(vm.Error) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 384, Col: 53} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, ". Treat the evidence as unknown, not as \"no incidents\".
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + if len(vm.Readers) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, r := range vm.Readers { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "
Reader nodeDisabledLatest headHead observedLast seenHistory sinceActive resetAudit failures
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var43 string + templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(r.NodeID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 404, Col: 28} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var44 string + templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(r.Disabled) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 405, Col: 24} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var45 string + templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(r.LatestBlock) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 406, Col: 27} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var46 string + templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(r.HeadObservedAt) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 407, Col: 37} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var47 string + templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(r.LastSeenAt) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 408, Col: 33} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var48 string + templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(r.HistoryStartedAt) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 409, Col: 39} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var49 string + templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(r.ActiveResetID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 410, Col: 47} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var50 string + templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(r.AuditFailures) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 411, Col: 29} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var51 string + templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(vm.Coverage) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 419, Col: 16} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "
History retained since ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var52 string + templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(vm.RetainedSince) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 421, Col: 44} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, " (events expire 30 days after last observation).
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(vm.Events) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "

No retained events for this filter. Absence of evidence never proves there was no affected traffic — disabled intervals, downtime, audit failures and expired history leave gaps. You may still scope and submit a manual range from canonical-chain investigation.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, e := range vm.Events { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "
KindReasonStageSource blockMessage IDTx hashBlock hashIncidentObservedExpires (UTC)
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var53 string + templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(e.Kind) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 451, Col: 20} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var54 string + templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(e.Reason) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 452, Col: 22} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var55 string + templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(e.Stage) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 453, Col: 21} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var56 string + templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(e.SourceBlock) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 454, Col: 27} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var57 string + templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(e.MessageID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 455, Col: 43} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var58 string + templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(e.TxHash) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 456, Col: 40} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var59 string + templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(e.BlockHash) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 457, Col: 43} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var60 string + templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(e.IncidentID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 458, Col: 44} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "×") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var61 string + templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(e.Observations) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 461, Col: 28} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var62 string + templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(e.FirstObserved) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 462, Col: 27} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var63 string + templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(e.LastObserved) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 463, Col: 26} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var64 string + templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(e.Expires) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `recovery.templ`, Line: 466, Col: 30} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if vm.NextCursor != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/verifier/pkg/admin/views/reschedule.templ b/verifier/pkg/admin/views/reschedule.templ new file mode 100644 index 000000000..e0b79be60 --- /dev/null +++ b/verifier/pkg/admin/views/reschedule.templ @@ -0,0 +1,209 @@ +package views + +// RescheduleTargetVM is one preview row: the rechecked state of a requested target. +// Excluded and unknown targets render disabled — unknown is never proof a replay is +// needed. Target is the original pipe-encoded form value, resubmitted on execute. +type RescheduleTargetVM struct { + Target string + OwnerID string + Queue string + JobID string + MessageID string + FailureCategory string + Status string // "ready" | "excluded" | "unknown" + Detail string + Executable bool +} + +// RescheduleResultVM is one executed target's outcome, plus its original target value +// so failed/skipped targets can be resubmitted as a retry. +type RescheduleResultVM struct { + Target string + OwnerID string + Queue string + JobID string + MessageID string + Outcome string // "success" | "failed" | "skipped" + Detail string +} + +func hasExecutable(targets []RescheduleTargetVM) bool { + for _, t := range targets { + if t.Executable { + return true + } + } + return false +} + +// retryTargets are the non-success outcomes the retry form resubmits; the +// execute gate re-checks each one, so no stale target is blindly re-executed. +func retryTargets(results []RescheduleResultVM) []RescheduleResultVM { + var out []RescheduleResultVM + for _, r := range results { + if r.Outcome != "success" { + out = append(out, r) + } + } + return out +} + +// ReschedulePreview renders the preview: a full page for plain form posts, a fragment +// replacing #reschedule-region for htmx requests. +templ ReschedulePreview(csrfToken string, targets []RescheduleTargetVM, errDetail string, fullPage bool) { + if fullPage { + @Layout("Reschedule preview") { + @reschedulePreviewContent(csrfToken, targets, errDetail) + } + } else { + @reschedulePreviewContent(csrfToken, targets, errDetail) + } +} + +templ reschedulePreviewContent(csrfToken string, targets []RescheduleTargetVM, errDetail string) { +
+

Reschedule preview

+ if errDetail != "" { +
{ errDetail }
+ } else { +
+ What reschedule does + Rescheduling a task-verifier job asks the policy endpoint again (re-verifies the message). + Rescheduling a storage-writer job retries delivering the saved verification result. + Neither re-checks source-chain finality. +
+ if hasExecutable(targets) { +
+ @CSRFField(csrfToken) + @reschedulePreviewTable(targets) +

+ + +

+
+ } else { + @reschedulePreviewTable(targets) + + } + } +
+} + +templ reschedulePreviewTable(targets []RescheduleTargetVM) { +
+ + + + + + + + + + + + + + + for _, t := range targets { + + + + + + + + + + + } + +
OwnerQueueJob IDMessage IDFailureWhat will changeStatus
+ if t.Executable { + + } else { + + } + { t.OwnerID }{ t.Queue }{ t.JobID }{ t.MessageID }{ t.FailureCategory } + if t.Executable { + archive → active; attempts reset; new retry deadline + } else { + — + } + + { t.Status } + if t.Detail != "" { +
+ { t.Detail } + } +
+
+} + +// RescheduleResults renders per-target outcomes after an execute post; failed/skipped +// targets get a retry form — every execution re-runs the safety gate, so a retry +// resubmits them without special handling. +templ RescheduleResults(csrfToken string, results []RescheduleResultVM, retryDuration string, auditDetail string, errDetail string, fullPage bool) { + if fullPage { + @Layout("Reschedule results") { + @rescheduleResultsContent(csrfToken, results, retryDuration, auditDetail, errDetail) + } + } else { + @rescheduleResultsContent(csrfToken, results, retryDuration, auditDetail, errDetail) + } +} + +templ rescheduleResultsContent(csrfToken string, results []RescheduleResultVM, retryDuration string, auditDetail string, errDetail string) { +
+

Reschedule results

+ if errDetail != "" { +
{ errDetail }
+ } else { + if auditDetail != "" { +
Action log write failed — the mutation happened but was not fully audited: { auditDetail }
+ } +
+ + + + + + + + + + + + + for _, r := range results { + + + + + + if r.Outcome == "success" { + + } else if r.Outcome == "failed" { + + } else { + + } + + + } + +
OwnerQueueJob IDMessage IDOutcomeDetail
{ r.OwnerID }{ r.Queue }{ r.JobID }{ r.MessageID }{ r.Outcome }{ r.Outcome }{ r.Outcome }{ r.Detail }
+
+ if len(retryTargets(results)) > 0 { +
+ @CSRFField(csrfToken) + for _, r := range retryTargets(results) { + + } + + +
+ } + } +
+} diff --git a/verifier/pkg/admin/views/reschedule_templ.go b/verifier/pkg/admin/views/reschedule_templ.go new file mode 100644 index 000000000..095ca795d --- /dev/null +++ b/verifier/pkg/admin/views/reschedule_templ.go @@ -0,0 +1,717 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package views + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +// RescheduleTargetVM is one preview row: the rechecked state of a requested target. +// Excluded and unknown targets render disabled — unknown is never proof a replay is +// needed. Target is the original pipe-encoded form value, resubmitted on execute. +type RescheduleTargetVM struct { + Target string + OwnerID string + Queue string + JobID string + MessageID string + FailureCategory string + Status string // "ready" | "excluded" | "unknown" + Detail string + Executable bool +} + +// RescheduleResultVM is one executed target's outcome, plus its original target value +// so failed/skipped targets can be resubmitted as a retry. +type RescheduleResultVM struct { + Target string + OwnerID string + Queue string + JobID string + MessageID string + Outcome string // "success" | "failed" | "skipped" + Detail string +} + +func hasExecutable(targets []RescheduleTargetVM) bool { + for _, t := range targets { + if t.Executable { + return true + } + } + return false +} + +// retryTargets are the non-success outcomes the retry form resubmits; the +// execute gate re-checks each one, so no stale target is blindly re-executed. +func retryTargets(results []RescheduleResultVM) []RescheduleResultVM { + var out []RescheduleResultVM + for _, r := range results { + if r.Outcome != "success" { + out = append(out, r) + } + } + return out +} + +// ReschedulePreview renders the preview: a full page for plain form posts, a fragment +// replacing #reschedule-region for htmx requests. +func ReschedulePreview(csrfToken string, targets []RescheduleTargetVM, errDetail string, fullPage bool) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if fullPage { + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = reschedulePreviewContent(csrfToken, targets, errDetail).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = Layout("Reschedule preview").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = reschedulePreviewContent(csrfToken, targets, errDetail).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +func reschedulePreviewContent(csrfToken string, targets []RescheduleTargetVM, errDetail string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Reschedule preview

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if errDetail != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(errDetail) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `reschedule.templ`, Line: 67, Col: 33} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
What reschedule does Rescheduling a task-verifier job asks the policy endpoint again (re-verifies the message). Rescheduling a storage-writer job retries delivering the saved verification result. Neither re-checks source-chain finality.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if hasExecutable(targets) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = CSRFField(csrfToken).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = reschedulePreviewTable(targets).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = reschedulePreviewTable(targets).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
No executable targets — nothing to reschedule.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func reschedulePreviewTable(targets []RescheduleTargetVM) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var5 := templ.GetChildren(ctx) + if templ_7745c5c3_Var5 == nil { + templ_7745c5c3_Var5 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, t := range targets { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
OwnerQueueJob IDMessage IDFailureWhat will changeStatus
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if t.Executable { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(t.OwnerID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `reschedule.templ`, Line: 117, Col: 27} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(t.Queue) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `reschedule.templ`, Line: 118, Col: 19} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(t.JobID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `reschedule.templ`, Line: 119, Col: 25} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(t.MessageID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `reschedule.templ`, Line: 120, Col: 41} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(t.FailureCategory) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `reschedule.templ`, Line: 121, Col: 29} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if t.Executable { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "archive → active; attempts reset; new retry deadline") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "—") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 = []any{"status-" + t.Status} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(t.Status) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `reschedule.templ`, Line: 130, Col: 56} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if t.Detail != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(t.Detail) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `reschedule.templ`, Line: 133, Col: 25} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// RescheduleResults renders per-target outcomes after an execute post; failed/skipped +// targets get a retry form — every execution re-runs the safety gate, so a retry +// resubmits them without special handling. +func RescheduleResults(csrfToken string, results []RescheduleResultVM, retryDuration string, auditDetail string, errDetail string, fullPage bool) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var16 := templ.GetChildren(ctx) + if templ_7745c5c3_Var16 == nil { + templ_7745c5c3_Var16 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if fullPage { + templ_7745c5c3_Var17 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = rescheduleResultsContent(csrfToken, results, retryDuration, auditDetail, errDetail).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = Layout("Reschedule results").Render(templ.WithChildren(ctx, templ_7745c5c3_Var17), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = rescheduleResultsContent(csrfToken, results, retryDuration, auditDetail, errDetail).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +func rescheduleResultsContent(csrfToken string, results []RescheduleResultVM, retryDuration string, auditDetail string, errDetail string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var18 := templ.GetChildren(ctx) + if templ_7745c5c3_Var18 == nil { + templ_7745c5c3_Var18 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "

Reschedule results

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if errDetail != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(errDetail) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `reschedule.templ`, Line: 160, Col: 33} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + if auditDetail != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
Action log write failed — the mutation happened but was not fully audited: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(auditDetail) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `reschedule.templ`, Line: 163, Col: 113} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, r := range results { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if r.Outcome == "success" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if r.Outcome == "failed" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
OwnerQueueJob IDMessage IDOutcomeDetail
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(r.OwnerID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `reschedule.templ`, Line: 180, Col: 29} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(r.Queue) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `reschedule.templ`, Line: 181, Col: 21} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(r.JobID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `reschedule.templ`, Line: 182, Col: 27} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(r.MessageID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `reschedule.templ`, Line: 183, Col: 43} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(r.Outcome) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `reschedule.templ`, Line: 185, Col: 44} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var26 string + templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(r.Outcome) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `reschedule.templ`, Line: 187, Col: 50} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var27 string + templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(r.Outcome) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `reschedule.templ`, Line: 189, Col: 24} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var28 string + templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(r.Detail) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `reschedule.templ`, Line: 191, Col: 29} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(retryTargets(results)) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = CSRFField(csrfToken).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, r := range retryTargets(results) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/verifier/pkg/admin/views/search.templ b/verifier/pkg/admin/views/search.templ new file mode 100644 index 000000000..45767fcf0 --- /dev/null +++ b/verifier/pkg/admin/views/search.templ @@ -0,0 +1,109 @@ +package views + +import ( + "encoding/hex" + "fmt" + "time" + + "github.com/smartcontractkit/chainlink-ccv/cli/jobqueue" +) + +// SearchResultsVM is the search outcome: rows found, genuinely empty, or a failed +// lookup (UnreachableDetail non-empty) — never conflated. +type SearchResultsVM struct { + UnreachableDetail string + Jobs []jobqueue.ArchivedJob +} + +// ArchiveRetention is the verifier's archive sweep window, for age/expiry display. +const ArchiveRetention = 30 * 24 * time.Hour + +// SearchPage renders the search form and, after a search, the results. +templ SearchPage(csrfToken string, results *SearchResultsVM, searchedIDs [][]byte) { + @Layout("Message search") { +

Message search

+

Search this verifier's failed-job archive.

+
+
+ @CSRFField(csrfToken) + +
+ +
+
+ Message ID format + Full 32-byte hex IDs, space or comma separated. +
+
+ if searchedIDs != nil { +

Results

+ @SearchResults(*results, "") + } + } +} + +// SearchResults is the fragment returned for htmx search posts. +templ SearchResults(results SearchResultsVM, errDetail string) { + if errDetail != "" { +
{ errDetail }
+ } else { + @SearchResultsList(results) + } +} + +templ SearchResultsList(results SearchResultsVM) { + if results.UnreachableDetail != "" { +
Lookup unavailable: { results.UnreachableDetail }. Treat the archive as unknown, not empty.
+ } else if len(results.Jobs) == 0 { +

No archived (failed) jobs for these message IDs.

+ } else { +
+ + + + + + + + + + + + + + + for _, job := range results.Jobs { + + + + + + + + + + + } + +
Message IDQueueOwnerFailureAttemptsArchivedArchive expires
{ messageIDHex(job.MessageID) }{ string(job.Queue) }{ job.OwnerID }{ job.FailureCategory }{ fmt.Sprint(job.AttemptCount) }{ formatTime(job.ArchivedAt) }{ archiveExpiry(job.ArchivedAt) } + detail +
+
+ } +} + +func messageIDHex(id []byte) string { return "0x" + hex.EncodeToString(id) } + +func formatTime(t *time.Time) string { + if t == nil { + return "—" + } + return t.UTC().Format(time.RFC3339) +} + +func archiveExpiry(archivedAt *time.Time) string { + if archivedAt == nil { + return "—" + } + return archivedAt.Add(ArchiveRetention).UTC().Format(time.RFC3339) +} diff --git a/verifier/pkg/admin/views/search_templ.go b/verifier/pkg/admin/views/search_templ.go new file mode 100644 index 000000000..e9c5e6506 --- /dev/null +++ b/verifier/pkg/admin/views/search_templ.go @@ -0,0 +1,329 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package views + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "encoding/hex" + "fmt" + "time" + + "github.com/smartcontractkit/chainlink-ccv/cli/jobqueue" +) + +// SearchResultsVM is the search outcome: rows found, genuinely empty, or a failed +// lookup (UnreachableDetail non-empty) — never conflated. +type SearchResultsVM struct { + UnreachableDetail string + Jobs []jobqueue.ArchivedJob +} + +// ArchiveRetention is the verifier's archive sweep window, for age/expiry display. +const ArchiveRetention = 30 * 24 * time.Hour + +// SearchPage renders the search form and, after a search, the results. +func SearchPage(csrfToken string, results *SearchResultsVM, searchedIDs [][]byte) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Message search

Search this verifier's failed-job archive.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = CSRFField(csrfToken).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
Message ID format Full 32-byte hex IDs, space or comma separated.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if searchedIDs != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

Results

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = SearchResults(*results, "").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) + templ_7745c5c3_Err = Layout("Message search").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// SearchResults is the fragment returned for htmx search posts. +func SearchResults(results SearchResultsVM, errDetail string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if errDetail != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(errDetail) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `search.templ`, Line: 48, Col: 32} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = SearchResultsList(results).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +func SearchResultsList(results SearchResultsVM) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var5 := templ.GetChildren(ctx) + if templ_7745c5c3_Var5 == nil { + templ_7745c5c3_Var5 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if results.UnreachableDetail != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
Lookup unavailable: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(results.UnreachableDetail) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `search.templ`, Line: 56, Col: 68} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, ". Treat the archive as unknown, not empty.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if len(results.Jobs) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "

No archived (failed) jobs for these message IDs.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, job := range results.Jobs { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
Message IDQueueOwnerFailureAttemptsArchivedArchive expires
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(messageIDHex(job.MessageID)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `search.templ`, Line: 77, Col: 58} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(string(job.Queue)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `search.templ`, Line: 78, Col: 30} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(job.OwnerID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `search.templ`, Line: 79, Col: 30} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(job.FailureCategory) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `search.templ`, Line: 80, Col: 32} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint(job.AttemptCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `search.templ`, Line: 81, Col: 41} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(formatTime(job.ArchivedAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `search.templ`, Line: 82, Col: 39} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(archiveExpiry(job.ArchivedAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `search.templ`, Line: 83, Col: 42} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "detail
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +func messageIDHex(id []byte) string { return "0x" + hex.EncodeToString(id) } + +func formatTime(t *time.Time) string { + if t == nil { + return "—" + } + return t.UTC().Format(time.RFC3339) +} + +func archiveExpiry(archivedAt *time.Time) string { + if archivedAt == nil { + return "—" + } + return archivedAt.Add(ArchiveRetention).UTC().Format(time.RFC3339) +} + +var _ = templruntime.GeneratedTemplate diff --git a/verifier/pkg/admin/views/static.go b/verifier/pkg/admin/views/static.go new file mode 100644 index 000000000..6cd8ada1b --- /dev/null +++ b/verifier/pkg/admin/views/static.go @@ -0,0 +1,9 @@ +package views + +import "embed" + +// StaticFS carries vendored browser assets (htmx, stylesheet, icon). Vendored, +// not CDN-loaded, so the console works on isolated networks. +// +//go:embed static +var StaticFS embed.FS diff --git a/verifier/pkg/admin/views/static/admin.css b/verifier/pkg/admin/views/static/admin.css new file mode 100644 index 000000000..ea6c12e77 --- /dev/null +++ b/verifier/pkg/admin/views/static/admin.css @@ -0,0 +1,288 @@ +:root { + --cl-blue: #0847f7; + --cl-blue-hover: #0636c9; + --cl-blue-soft: #639cff; + --cl-navy: #0c162c; + --cl-navy-2: #131f3c; + --cl-bg: #f5f7fc; + --cl-card: #ffffff; + --cl-border: #dfe5f1; + --cl-text: #1b2a4e; + --cl-muted: #5b6b8c; + --cl-green: #067647; + --cl-green-bg: #dcfae6; + --cl-red: #b42318; + --cl-red-bg: #fee4e2; + --cl-amber-text: #93370d; + --cl-amber-bg: #fffaeb; + --cl-amber-border: #fedf89; + --cl-gray-text: #475467; + --cl-gray-bg: #f2f4f7; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--cl-bg); + color: var(--cl-text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-size: 0.92rem; + line-height: 1.5; +} + +/* Header */ +.topbar { + background: linear-gradient(180deg, var(--cl-navy-2), var(--cl-navy)); + border-bottom: 2px solid var(--cl-blue); + box-shadow: 0 2px 8px rgba(12, 22, 44, 0.25); +} +.topbar-inner { + max-width: 76rem; + margin: 0 auto; + padding: 0 1.5rem; + display: flex; + align-items: center; + gap: 2rem; + min-height: 3.6rem; +} +.brand { + display: flex; + align-items: center; + gap: 0.6rem; + color: #ffffff; + text-decoration: none; + font-size: 1.05rem; + letter-spacing: 0.01em; +} +.brand strong { font-weight: 700; } +.topbar nav { display: flex; gap: 0.25rem; flex-wrap: wrap; } +.topbar nav a { + color: #aab6d3; + text-decoration: none; + padding: 0.45rem 0.9rem; + border-radius: 8px; + font-weight: 500; +} +.topbar nav a:hover { background: rgba(255, 255, 255, 0.08); color: #ffffff; } +.topbar nav a.active { background: var(--cl-blue); color: #ffffff; } + +/* Collapsible explanations: long caveats stay one click away, not on the page face. */ +details.explain { + margin: 0.6rem 0; + font-size: 0.8rem; + color: var(--cl-muted); +} +details.explain > summary { + cursor: pointer; + color: var(--cl-gray-text); + font-weight: 600; + user-select: none; + list-style: none; + display: inline-flex; + align-items: center; + gap: 0.3rem; +} +details.explain > summary::before { + content: "▸"; + color: var(--cl-blue-soft); + transition: transform 0.12s ease; +} +details.explain[open] > summary::before { transform: rotate(90deg); } +details.explain > summary::marker { display: none; } +details.explain[open] > *:not(summary) { + margin: 0.35rem 0 0; + line-height: 1.55; +} + +.container { max-width: 76rem; margin: 1.75rem auto 3rem; padding: 0 1.5rem; } + +.footer { + max-width: 76rem; + margin: 0 auto; + padding: 1.25rem 1.5rem 2rem; + border-top: 1px solid var(--cl-border); + color: var(--cl-muted); + font-size: 0.8rem; +} + +/* Type */ +h1 { + font-size: 1.45rem; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--cl-navy); + margin: 0.25rem 0 0.9rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--cl-border); + position: relative; +} +h1::after { + content: ""; + position: absolute; + left: 0; + bottom: -1px; + width: 3rem; + height: 2px; + background: var(--cl-blue); + border-radius: 2px; +} +h2 { + font-size: 1.1rem; + font-weight: 650; + color: var(--cl-navy); + margin: 2rem 0 0.6rem; + padding-bottom: 0.35rem; + border-bottom: 1px solid var(--cl-border); +} +h3 { font-size: 0.98rem; font-weight: 650; color: var(--cl-navy); margin: 1.25rem 0 0.4rem; } +h4 { font-size: 0.92rem; font-weight: 650; color: var(--cl-navy); margin: 1rem 0 0.3rem; } +p { margin: 0.5rem 0; } +small { color: var(--cl-muted); } +a { color: var(--cl-blue); text-decoration: none; } +a:hover { text-decoration: underline; } + +code { + background: #eef2fb; + color: #1d3a8f; + padding: 0.08rem 0.35rem; + border-radius: 6px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85em; +} +code.mid { word-break: break-all; } + +/* Cards and tables */ +.card { + background: var(--cl-card); + border: 1px solid var(--cl-border); + border-radius: 12px; + padding: 1rem 1.25rem; + margin: 1rem 0; + box-shadow: 0 1px 2px rgba(12, 22, 44, 0.05); +} +.table-wrap { + background: var(--cl-card); + border: 1px solid var(--cl-border); + border-radius: 12px; + margin: 0.75rem 0; + overflow-x: auto; + box-shadow: 0 1px 2px rgba(12, 22, 44, 0.05); +} +table { border-collapse: collapse; width: 100%; } +th { + background: #f8fafe; + color: var(--cl-muted); + font-size: 0.72rem; + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0.6rem 0.8rem; + text-align: left; + white-space: nowrap; +} +td { + border-top: 1px solid #eef2f8; + padding: 0.55rem 0.8rem; + text-align: left; + vertical-align: top; +} +tbody tr:hover { background: #f6f9ff; } + +/* Status pills (span-scoped: td/small usages stay plain colored text) */ +span.state-ready, span.state-unreachable { + display: inline-block; + padding: 0.12rem 0.6rem; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 600; + white-space: nowrap; +} +span.state-ready { color: var(--cl-green); background: var(--cl-green-bg); } +span.state-unreachable { color: var(--cl-red); background: var(--cl-red-bg); } +td.state-ready { color: var(--cl-green); font-weight: 600; } +td.state-unreachable { color: var(--cl-red); font-weight: 600; } +p.state-ready { color: var(--cl-green); font-weight: 600; } +small.state-unreachable { color: var(--cl-red); } + +/* Preview status pills (reschedule targets: ready / excluded / unknown). */ +.status-ready, .status-excluded, .status-unknown { + display: inline-block; + padding: 0.12rem 0.6rem; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 600; + white-space: nowrap; +} +.status-ready { color: var(--cl-green); background: var(--cl-green-bg); } +.status-excluded { color: var(--cl-gray-text); background: var(--cl-gray-bg); } +.status-unknown { color: var(--cl-amber-text); background: var(--cl-amber-bg); } + +/* Callouts */ +.banner { + background: var(--cl-amber-bg); + border: 1px solid var(--cl-amber-border); + border-left: 4px solid #f79009; + border-radius: 8px; + color: var(--cl-amber-text); + padding: 0.7rem 1rem; + margin: 0.9rem 0; +} +.error { + background: #fef3f2; + border: 1px solid #fecdca; + border-left: 4px solid #d92d20; + border-radius: 8px; + color: var(--cl-red); + padding: 0.7rem 1rem; + margin: 0.9rem 0; +} +.banner a, .error a { color: inherit; text-decoration: underline; } + +/* Forms */ +button, input[type="submit"] { + background: var(--cl-blue); + color: #ffffff; + border: none; + border-radius: 8px; + padding: 0.5rem 1.05rem; + font: inherit; + font-weight: 600; + cursor: pointer; + transition: background 0.15s ease, box-shadow 0.15s ease; +} +button:hover:not(:disabled) { background: var(--cl-blue-hover); box-shadow: 0 2px 6px rgba(8, 71, 247, 0.3); } +button:disabled { background: #98a2b3; cursor: not-allowed; } +td button, form.inline button { padding: 0.28rem 0.65rem; font-size: 0.8rem; } +form.inline { display: inline; } +button.htmx-request { opacity: 0.6; pointer-events: none; } + +input[type="text"], input[type="number"], textarea, select { + border: 1px solid #cdd5e4; + border-radius: 8px; + padding: 0.45rem 0.6rem; + font: inherit; + color: var(--cl-text); + background: #ffffff; +} +input:focus, textarea:focus, select:focus { + outline: 2px solid rgba(8, 71, 247, 0.3); + border-color: var(--cl-blue); +} +input[type="number"] { width: 11rem; } +textarea { + width: 100%; + min-height: 4.5rem; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85rem; +} +input[type="checkbox"], input[type="radio"] { accent-color: var(--cl-blue); } +label { font-weight: 500; margin-right: 1rem; } +fieldset { + border: 1px solid var(--cl-border); + border-radius: 10px; + padding: 0.6rem 1rem 0.9rem; + margin: 0.9rem 0; +} +legend { font-weight: 650; color: var(--cl-navy); padding: 0 0.4rem; font-size: 0.85rem; } +fieldset label { display: inline-block; margin: 0.25rem 1rem 0.25rem 0; font-weight: 400; } diff --git a/verifier/pkg/admin/views/static/favicon.svg b/verifier/pkg/admin/views/static/favicon.svg new file mode 100644 index 000000000..c891e8a40 --- /dev/null +++ b/verifier/pkg/admin/views/static/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/verifier/pkg/admin/views/static/htmx.min.js b/verifier/pkg/admin/views/static/htmx.min.js new file mode 100644 index 000000000..59937d712 --- /dev/null +++ b/verifier/pkg/admin/views/static/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","
");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend"," ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/verifier/pkg/vsecrets/doccomments_gen.go b/verifier/pkg/vsecrets/doccomments_gen.go index 9a19834ca..585159795 100644 --- a/verifier/pkg/vsecrets/doccomments_gen.go +++ b/verifier/pkg/vsecrets/doccomments_gen.go @@ -4,6 +4,13 @@ package vsecrets import "github.com/smartcontractkit/chainlink-common/x/config/commentparsing" +func (AdminUISecret) DocComments() map[string]commentparsing.FieldDoc { + return map[string]commentparsing.FieldDoc{ + "Password": {Comment: "Password is the basic-auth password."}, + "Username": {Comment: "Username is the basic-auth username; it also becomes the action-log actor."}, + } +} + func (AggregatorSecret) DocComments() map[string]commentparsing.FieldDoc { return map[string]commentparsing.FieldDoc{ "APIKey": {Comment: "APIKey is this aggregator's inbound HMAC API key."}, @@ -27,6 +34,7 @@ func (PolicyHookSecret) DocComments() map[string]commentparsing.FieldDoc { func (SecretsFile) DocComments() map[string]commentparsing.FieldDoc { return map[string]commentparsing.FieldDoc{ + "AdminUI": {Comment: "AdminUI is the optional basic-auth credential gating the admin console UI. Only the\nadmin console consumes it, when this file is the console's secrets file; the verifier\nbinaries ignore it."}, "PolicyHook": {Comment: "PolicyHook is the optional credential the committee verifier presents to the operator's\npolicy endpoint. Omit it to call the endpoint unauthenticated."}, } } diff --git a/verifier/pkg/vsecrets/vsecrets.go b/verifier/pkg/vsecrets/vsecrets.go index ce82687a1..dd1516cfd 100644 --- a/verifier/pkg/vsecrets/vsecrets.go +++ b/verifier/pkg/vsecrets/vsecrets.go @@ -43,6 +43,20 @@ type SecretsFile struct { // PolicyHook is the optional credential the committee verifier presents to the operator's // policy endpoint. Omit it to call the endpoint unauthenticated. PolicyHook *PolicyHookSecret `toml:"policy_hook"` + // AdminUI is the optional basic-auth credential gating the admin console UI. Only the + // admin console consumes it, when this file is the console's secrets file; the verifier + // binaries ignore it. + AdminUI *AdminUISecret `toml:"admin_ui"` +} + +// AdminUISecret is the basic-auth credential the admin console gates its UI with, as +// declared in the [admin_ui] table. Both fields are required together; a half-supplied +// pair is a startup error rather than a silent downgrade to unauthenticated serving. +type AdminUISecret struct { + // Username is the basic-auth username; it also becomes the action-log actor. + Username string `toml:"username"` + // Password is the basic-auth password. + Password string `toml:"password"` } // PolicyHookSecret is the HMAC credential the committee verifier presents to the operator's @@ -120,6 +134,8 @@ type VerifierSecrets struct { aggregators AggregatorSecrets // policyHook is nil when the file supplied no [policy_hook] (env is then used). policyHook *PolicyHookSecret + // adminUI is nil when the file supplied no [admin_ui]; only the admin console reads it. + adminUI *AdminUISecret } // ResolveSecretsPath returns the secrets file path from envVar, or defaultPath when unset. @@ -169,7 +185,7 @@ func Load(path string) (*VerifierSecrets, error) { return nil, fmt.Errorf("invalid verifier secrets file %q: %w", path, err) } - return &VerifierSecrets{dbURL: file.DB.URL, aggregators: aggregators, policyHook: file.PolicyHook}, nil + return &VerifierSecrets{dbURL: file.DB.URL, aggregators: aggregators, policyHook: file.PolicyHook, adminUI: file.AdminUI}, nil } // DatabaseURL returns the application storage DB URL: the secrets file value when present, otherwise @@ -200,3 +216,12 @@ func (s *VerifierSecrets) PolicyHookSecret() *PolicyHookSecret { } return s.policyHook } + +// AdminUIAuth returns the console basic-auth pair from the file, or nil when the file +// supplied no [admin_ui]. Only the admin console consumes it. +func (s *VerifierSecrets) AdminUIAuth() *AdminUISecret { + if s == nil { + return nil + } + return s.adminUI +}