├── web
├── build
│ └── .gitkeep
├── .nvmrc
├── src
│ ├── assets
│ │ ├── .gitkeep
│ │ ├── forky_logo.png
│ │ └── forky_logo_small.png
│ ├── vite-env.d.ts
│ ├── utils
│ │ ├── environment.ts
│ │ ├── darkmode.ts
│ │ ├── maths.ts
│ │ ├── strings.ts
│ │ ├── tailwind.ts
│ │ ├── functions.ts
│ │ ├── testing.tsx
│ │ └── __tests__
│ │ │ ├── strings.test.ts
│ │ │ ├── maths.test.ts
│ │ │ └── functions.test.ts
│ ├── mocks
│ │ ├── server.ts
│ │ ├── browser.ts
│ │ └── handlers.ts
│ ├── styles
│ │ └── global.css
│ ├── providers
│ │ ├── selection.tsx
│ │ ├── focus.tsx
│ │ ├── ethereum.tsx
│ │ └── application.tsx
│ ├── NotFound.tsx
│ ├── components
│ │ ├── Loading.tsx
│ │ ├── Icon.tsx
│ │ ├── Edge.tsx
│ │ ├── ConcatNode.tsx
│ │ ├── Download.tsx
│ │ ├── ModeToggle.tsx
│ │ ├── ProgressCircle.tsx
│ │ ├── SlotBoundary.tsx
│ │ ├── SlotDial.tsx
│ │ ├── Ruler.tsx
│ │ ├── EpochDial.tsx
│ │ ├── Walker.tsx
│ │ ├── Slot.tsx
│ │ ├── SnapshotMarker.tsx
│ │ ├── EditableInput.tsx
│ │ ├── TimeDrag.tsx
│ │ └── WeightedNode.tsx
│ ├── hooks
│ │ ├── useWindowSize.ts
│ │ ├── useOutsideInteraction.ts
│ │ ├── __tests__
│ │ │ ├── usePointer.test.ts
│ │ │ ├── useWindowSize.test.ts
│ │ │ ├── useOutsideInteraction.test.ts
│ │ │ └── useQuery.test.ts
│ │ ├── usePointer.ts
│ │ ├── useQuery.ts
│ │ ├── useActive.ts
│ │ └── useGraph.ts
│ ├── api
│ │ ├── frames.ts
│ │ ├── metadata.ts
│ │ └── ethereum.ts
│ ├── ErrorBoundary.tsx
│ ├── parts
│ │ ├── Timeline.tsx
│ │ ├── Stage.tsx
│ │ └── FrameFooter.tsx
│ ├── contexts
│ │ ├── ethereum.tsx
│ │ └── selection.tsx
│ ├── index.tsx
│ ├── types
│ │ ├── graph.ts
│ │ └── api.ts
│ └── App.tsx
├── public
│ ├── favicon.ico
│ ├── humans.txt
│ ├── favicon-96x96.png
│ ├── apple-touch-icon.png
│ ├── web-app-manifest-192x192.png
│ ├── web-app-manifest-512x512.png
│ └── site.webmanifest
├── static.go
├── tsconfig.vitest.json
├── tsconfig.json
├── .prettierrc
├── vitest.setup.ts
├── .gitignore
├── README.md
├── tsconfig.node.json
├── tailwind.config.js
├── vite.config.ts
├── vitest.config.ts
├── index.html
├── eslint.config.js
├── tsconfig.app.json
└── package.json
├── .github
├── CODEOWNERS
├── dependabot.yml
└── workflows
│ ├── alpha-releases.yaml
│ ├── goreleaser.yaml
│ └── vet.yaml
├── renovate.json
├── .gitignore
├── goreleaser-scratch.Dockerfile
├── makefile
├── pkg
├── forky
│ ├── source
│ │ ├── type.go
│ │ ├── error.go
│ │ ├── config.go
│ │ ├── option.go
│ │ ├── metrics.go
│ │ └── source.go
│ ├── api
│ │ ├── http
│ │ │ ├── error.go
│ │ │ ├── metrics.go
│ │ │ ├── content_type.go
│ │ │ ├── http.go
│ │ │ ├── routing.go
│ │ │ └── response.go
│ │ ├── option.go
│ │ ├── config.go
│ │ ├── frames.go
│ │ ├── ethereum.go
│ │ └── http.go
│ ├── db
│ │ ├── config.go
│ │ ├── option.go
│ │ ├── frame_metadata_label.go
│ │ ├── pagination.go
│ │ ├── operation.go
│ │ ├── event_source.go
│ │ ├── metrics.go
│ │ ├── filters.go
│ │ └── frame_metadata.go
│ ├── store
│ │ ├── errors.go
│ │ ├── config.go
│ │ ├── type.go
│ │ ├── option.go
│ │ ├── store.go
│ │ ├── memory.go
│ │ ├── filesystem.go
│ │ └── metrics.go
│ ├── service
│ │ ├── errors.go
│ │ ├── config.go
│ │ ├── option.go
│ │ ├── pagination.go
│ │ ├── operations.go
│ │ ├── purger.go
│ │ ├── forkchoice.go
│ │ └── metrics.go
│ ├── human
│ │ └── duration.go
│ ├── http.go
│ ├── ethereum
│ │ ├── client.go
│ │ ├── config.go
│ │ ├── ethereum.go
│ │ └── ethereum_test.go
│ ├── types
│ │ └── event_source.go
│ ├── config.go
│ └── forkchoice.go
├── yaml
│ └── raw_config.go
└── version
│ └── version.go
├── .vscode
├── launch.json
└── settings.json
├── goreleaser-debian.Dockerfile
├── Dockerfile
├── main.go
├── .golangci.yml
├── example_config.yaml
└── cmd
└── root.go
/web/build/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/.nvmrc:
--------------------------------------------------------------------------------
1 | 23.11.0
2 |
--------------------------------------------------------------------------------
/web/src/assets/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @samcm
2 | * @savid
--------------------------------------------------------------------------------
/web/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json"
3 | }
4 |
--------------------------------------------------------------------------------
/web/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ethpandaops/forky/HEAD/web/public/favicon.ico
--------------------------------------------------------------------------------
/web/public/humans.txt:
--------------------------------------------------------------------------------
1 | Team: EF ethPandaOps
2 | Twitter: @savid @samcmau
3 | Github: @savid @samcm
4 |
--------------------------------------------------------------------------------
/web/static.go:
--------------------------------------------------------------------------------
1 | package static
2 |
3 | import "embed"
4 |
5 | //go:embed build/*
6 | var FS embed.FS
7 |
--------------------------------------------------------------------------------
/web/public/favicon-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ethpandaops/forky/HEAD/web/public/favicon-96x96.png
--------------------------------------------------------------------------------
/web/src/assets/forky_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ethpandaops/forky/HEAD/web/src/assets/forky_logo.png
--------------------------------------------------------------------------------
/web/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ethpandaops/forky/HEAD/web/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | bin
2 | config.yaml
3 | config2.yaml
4 | __debug_bin*
5 | dist/
6 | checkpointz
7 | store.db
8 | index.db
9 |
--------------------------------------------------------------------------------
/web/src/assets/forky_logo_small.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ethpandaops/forky/HEAD/web/src/assets/forky_logo_small.png
--------------------------------------------------------------------------------
/goreleaser-scratch.Dockerfile:
--------------------------------------------------------------------------------
1 | FROM gcr.io/distroless/static-debian11:latest
2 | COPY forky* /forky
3 | ENTRYPOINT ["/forky"]
4 |
--------------------------------------------------------------------------------
/makefile:
--------------------------------------------------------------------------------
1 | build-web:
2 | @echo "Building web frontend..."
3 | @npm --prefix ./web install && npm --prefix ./web run build
4 |
--------------------------------------------------------------------------------
/web/public/web-app-manifest-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ethpandaops/forky/HEAD/web/public/web-app-manifest-192x192.png
--------------------------------------------------------------------------------
/web/public/web-app-manifest-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ethpandaops/forky/HEAD/web/public/web-app-manifest-512x512.png
--------------------------------------------------------------------------------
/web/src/utils/environment.ts:
--------------------------------------------------------------------------------
1 | export const BASE_URL = import.meta.env.VITEST
2 | ? 'http://localhost:5555/'
3 | : import.meta.env.BASE_URL;
4 |
--------------------------------------------------------------------------------
/pkg/forky/source/type.go:
--------------------------------------------------------------------------------
1 | package source
2 |
3 | type Data string
4 |
5 | var (
6 | DataFrame Data = "frame"
7 | DataBlock Data = "block"
8 | )
9 |
--------------------------------------------------------------------------------
/pkg/forky/api/http/error.go:
--------------------------------------------------------------------------------
1 | package http
2 |
3 | type ErrorContainer struct {
4 | Code int `json:"code"`
5 | Message string `json:"message"`
6 | }
7 |
--------------------------------------------------------------------------------
/pkg/forky/db/config.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | type IndexerConfig struct {
4 | DSN string `yaml:"dsn"`
5 | DriverName string `yaml:"driver_name"`
6 | }
7 |
--------------------------------------------------------------------------------
/web/src/mocks/server.ts:
--------------------------------------------------------------------------------
1 | import { setupServer } from 'msw/node';
2 |
3 | import { handlers } from '@app/mocks/handlers';
4 |
5 | export const server = setupServer(...handlers);
6 |
--------------------------------------------------------------------------------
/web/src/mocks/browser.ts:
--------------------------------------------------------------------------------
1 | import { setupWorker } from 'msw/browser';
2 |
3 | import { handlers } from '@app/mocks/handlers';
4 |
5 | export const worker = setupWorker(...handlers);
6 |
--------------------------------------------------------------------------------
/web/tsconfig.vitest.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.app.json",
3 | "compilerOptions": {
4 | "composite": true,
5 | "types": ["vitest/globals"]
6 | },
7 | "exclude": []
8 | }
9 |
--------------------------------------------------------------------------------
/pkg/forky/source/error.go:
--------------------------------------------------------------------------------
1 | package source
2 |
3 | import "errors"
4 |
5 | var (
6 | ErrMissingName = errors.New("missing name")
7 |
8 | ErrFrameNotFound = errors.New("frame not found")
9 | )
10 |
--------------------------------------------------------------------------------
/web/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" },
6 | { "path": "./tsconfig.vitest.json" }
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/web/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 2,
3 | "useTabs": false,
4 | "semi": true,
5 | "singleQuote": true,
6 | "trailingComma": "es5",
7 | "bracketSpacing": true,
8 | "arrowParens": "avoid",
9 | "printWidth": 120
10 | }
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: github-actions
4 | directory: /
5 | schedule:
6 | interval: monthly
7 | groups:
8 | actions:
9 | patterns:
10 | - '*'
11 |
--------------------------------------------------------------------------------
/pkg/forky/store/errors.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | import "errors"
4 |
5 | var (
6 | ErrFrameNotFound = errors.New("frame not found")
7 | ErrFrameAlreadyStored = errors.New("frame already stored")
8 | ErrFrameInvalid = errors.New("frame invalid")
9 | )
10 |
--------------------------------------------------------------------------------
/pkg/forky/source/config.go:
--------------------------------------------------------------------------------
1 | package source
2 |
3 | import (
4 | "github.com/ethpandaops/forky/pkg/yaml"
5 | )
6 |
7 | type Config struct {
8 | Name string `yaml:"name"`
9 | Type string `yaml:"type"`
10 | Config yaml.RawMessage `yaml:"config"`
11 | }
12 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | "name": "Debug Go",
6 | "type": "go",
7 | "request": "launch",
8 | "mode": "debug",
9 | "program": "${workspaceRoot}",
10 | "args": [],
11 | }
12 | ]
13 | }
--------------------------------------------------------------------------------
/goreleaser-debian.Dockerfile:
--------------------------------------------------------------------------------
1 | FROM debian:latest
2 | RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
3 | libssl-dev \
4 | ca-certificates \
5 | && apt-get clean \
6 | && rm -rf /var/lib/apt/lists/*
7 | COPY forky* /forky
8 | ENTRYPOINT ["/forky"]
9 |
--------------------------------------------------------------------------------
/web/src/styles/global.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 |
3 | @custom-variant dark (&:where(.dark, .dark *));
4 |
5 | /* fix for safari https://github.com/mdn/browser-compat-data/issues/17726 */
6 | .drop-shadow-sm,
7 | .drop-shadow,
8 | .drop-shadow-md,
9 | .drop-shadow-lg,
10 | .drop-shadow-xl,
11 | .drop-shadow-2xl {
12 | transform: translateZ(0)
13 | }
--------------------------------------------------------------------------------
/pkg/yaml/raw_config.go:
--------------------------------------------------------------------------------
1 | package yaml
2 |
3 | type RawMessage struct {
4 | unmarshal func(interface{}) error
5 | }
6 |
7 | func (r *RawMessage) UnmarshalYAML(unmarshal func(interface{}) error) error {
8 | r.unmarshal = unmarshal
9 |
10 | return nil
11 | }
12 |
13 | func (r *RawMessage) Unmarshal(v interface{}) error {
14 | return r.unmarshal(v)
15 | }
16 |
--------------------------------------------------------------------------------
/pkg/forky/service/errors.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import "errors"
4 |
5 | var (
6 | ErrInvalidID = errors.New("invalid id")
7 | ErrInvalidFilter = errors.New("invalid filter")
8 | ErrUnknownServerErrorOccurred = errors.New("unknown server error occurred")
9 | ErrFrameNotFound = errors.New("frame not found")
10 | )
11 |
--------------------------------------------------------------------------------
/web/src/providers/selection.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react';
2 |
3 | import { Context, useValue } from '@contexts/selection';
4 |
5 | export interface Props {
6 | children: ReactNode;
7 | }
8 |
9 | export default function Provider({ children }: Props) {
10 | const value = useValue();
11 | return {children};
12 | }
13 |
--------------------------------------------------------------------------------
/web/vitest.setup.ts:
--------------------------------------------------------------------------------
1 | import { cleanup } from '@testing-library/react';
2 | import { afterEach, beforeAll, afterAll } from 'vitest';
3 |
4 | import { server } from '@app/mocks/server';
5 |
6 | afterEach(() => {
7 | cleanup();
8 | server.resetHandlers();
9 | });
10 |
11 | beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
12 |
13 | afterAll(() => server.close());
14 |
--------------------------------------------------------------------------------
/web/src/NotFound.tsx:
--------------------------------------------------------------------------------
1 | import Logo from '@assets/forky_logo.png';
2 |
3 | export default function NotFound() {
4 | return (
5 |
6 |

7 |
Page not found
8 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/web/src/utils/darkmode.ts:
--------------------------------------------------------------------------------
1 | export function hasDarkPreference() {
2 | if (window.matchMedia) {
3 | const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
4 | return darkModeMediaQuery.matches;
5 | }
6 | return false;
7 | }
8 |
9 | export function hasDarkLocalStorage() {
10 | if (!window.localStorage) return false;
11 | return window.localStorage.isDarkMode === 'true';
12 | }
13 |
--------------------------------------------------------------------------------
/web/src/providers/focus.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react';
2 |
3 | import { Context, useValue, ValueProps } from '@contexts/focus';
4 |
5 | export interface Props extends ValueProps {
6 | children: ReactNode;
7 | }
8 |
9 | export default function Provider({ children, ...valueProps }: Props) {
10 | const value = useValue(valueProps);
11 | return {children};
12 | }
13 |
--------------------------------------------------------------------------------
/pkg/forky/store/config.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/ethpandaops/forky/pkg/yaml"
7 | )
8 |
9 | type Config struct {
10 | Type Type `yaml:"type"`
11 | Config yaml.RawMessage `yaml:"config"`
12 | }
13 |
14 | func (c *Config) Validate() error {
15 | if !IsValidStoreType(c.Type) {
16 | return fmt.Errorf("invalid store type: %s", c.Type)
17 | }
18 |
19 | return nil
20 | }
21 |
--------------------------------------------------------------------------------
/web/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | build
14 | *.local
15 |
16 | # Editor directories and files
17 | .vscode/*
18 | !.vscode/extensions.json
19 | .idea
20 | .DS_Store
21 | *.suo
22 | *.ntvs*
23 | *.njsproj
24 | *.sln
25 | *.sw?
26 | *.tsbuildinfo
27 | eslint_report.json
28 | coverage
--------------------------------------------------------------------------------
/web/README.md:
--------------------------------------------------------------------------------
1 | # Forkchoice Frontend
2 |
3 | Forkchoice bundled frontend
4 |
5 | ## Get started
6 |
7 | ```
8 | npm install
9 |
10 | npm run dev
11 | ```
12 |
13 | ### Building
14 |
15 | ```
16 | npm run build
17 | ```
18 |
19 | ### Tests
20 |
21 | ```
22 | npm run tests
23 | ```
24 |
25 | ## VSCode
26 |
27 | Install the [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) for best experience.
28 |
--------------------------------------------------------------------------------
/web/src/providers/ethereum.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react';
2 |
3 | import { Context, useValue, ValueProps } from '@contexts/ethereum';
4 |
5 | export interface Props extends ValueProps {
6 | children: ReactNode;
7 | }
8 |
9 | export default function Provider({ children, ...valueProps }: Props) {
10 | const value = useValue(valueProps);
11 | return {children};
12 | }
13 |
--------------------------------------------------------------------------------
/pkg/version/version.go:
--------------------------------------------------------------------------------
1 | package version
2 |
3 | import (
4 | "fmt"
5 | "runtime"
6 | )
7 |
8 | var (
9 | Release = "dev"
10 | GitCommit = "dev"
11 | )
12 |
13 | func Full() string {
14 | return fmt.Sprintf("Forky/%s", Short())
15 | }
16 |
17 | func Short() string {
18 | return fmt.Sprintf("%s-%s", Release, GitCommit)
19 | }
20 |
21 | func FullVWithGOOS() string {
22 | return fmt.Sprintf("%s/%s", Full(), runtime.GOOS)
23 | }
24 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.19 AS builder
2 | WORKDIR /src
3 | COPY go.sum go.mod ./
4 | RUN go mod download
5 | COPY . .
6 | RUN CGO_ENABLED=0 go build -o /bin/app .
7 |
8 | FROM ubuntu:latest
9 | RUN apt-get update && apt-get -y upgrade && apt-get install -y --no-install-recommends \
10 | libssl-dev \
11 | ca-certificates \
12 | && apt-get clean \
13 | && rm -rf /var/lib/apt/lists/*
14 | COPY --from=builder /bin/app /forky
15 | EXPOSE 5555
16 | ENTRYPOINT ["/forky"]
17 |
--------------------------------------------------------------------------------
/web/src/utils/maths.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Given the start angle in radians, and the percentage of the circle to draw,
3 | * return the end angle in radians.
4 | */
5 | export function arcToRadiansByPercentage(fromRadians: number, percentage: number): number {
6 | if (percentage < 0 || Number.isNaN(percentage)) return 0;
7 | const radiansPerPercent = (2 * Math.PI) / 100;
8 | const endAngleRadians = fromRadians + percentage * radiansPerPercent;
9 | return endAngleRadians;
10 | }
11 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "eslint.workingDirectories": [
3 | {
4 | "mode": "auto"
5 | }
6 | ],
7 | "eslint.validate": [
8 | "javascript",
9 | "javascriptreact",
10 | "typescript",
11 | "typescriptreact",
12 | "mdx"
13 | ],
14 | "[typescriptreact]": {
15 | "editor.defaultFormatter": "dbaeumer.vscode-eslint"
16 | },
17 | "editor.codeActionsOnSave": {
18 | "source.fixAll.eslint": "explicit"
19 | },
20 | "css.lint.unknownAtRules": "ignore"
21 | }
22 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "log"
5 | "os"
6 | "os/signal"
7 | "syscall"
8 |
9 | //nolint:gosec // running pprof on a separate port
10 | _ "net/http/pprof"
11 |
12 | "github.com/ethpandaops/forky/cmd"
13 | _ "github.com/lib/pq"
14 | )
15 |
16 | func main() {
17 | cancel := make(chan os.Signal, 1)
18 | signal.Notify(cancel, syscall.SIGTERM, syscall.SIGINT)
19 |
20 | go cmd.Execute()
21 |
22 | sig := <-cancel
23 | log.Printf("Caught signal: %v", sig)
24 |
25 | os.Exit(0)
26 | }
27 |
--------------------------------------------------------------------------------
/web/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "lib": ["ES2023"],
5 | "module": "ESNext",
6 | "skipLibCheck": true,
7 | "moduleResolution": "bundler",
8 | "allowImportingTsExtensions": true,
9 | "isolatedModules": true,
10 | "moduleDetection": "force",
11 | "noEmit": true,
12 | "strict": true,
13 | "noUnusedLocals": true,
14 | "noUnusedParameters": true,
15 | "noFallthroughCasesInSwitch": true
16 | },
17 | "include": ["vite.config.ts"]
18 | }
19 |
--------------------------------------------------------------------------------
/web/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "MyWebSite",
3 | "short_name": "MySite",
4 | "icons": [
5 | {
6 | "src": "/web-app-manifest-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png",
9 | "purpose": "maskable"
10 | },
11 | {
12 | "src": "/web-app-manifest-512x512.png",
13 | "sizes": "512x512",
14 | "type": "image/png",
15 | "purpose": "maskable"
16 | }
17 | ],
18 | "theme_color": "#ffffff",
19 | "background_color": "#ffffff",
20 | "display": "standalone"
21 | }
--------------------------------------------------------------------------------
/pkg/forky/store/type.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | type Type string
4 |
5 | const (
6 | UnknownStore Type = "unknown"
7 | FileSystemStoreType Type = "fs"
8 | S3StoreType Type = "s3"
9 | MemoryStoreType Type = "memory"
10 | )
11 |
12 | func IsValidStoreType(st Type) bool {
13 | switch st {
14 | case FileSystemStoreType, S3StoreType, MemoryStoreType:
15 | return true
16 | default:
17 | return false
18 | }
19 | }
20 |
21 | type DataType string
22 |
23 | const (
24 | UnknownDataType DataType = "unknown"
25 | FrameDataType DataType = "frame"
26 | BlockDataType DataType = "block"
27 | )
28 |
--------------------------------------------------------------------------------
/pkg/forky/api/option.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | type Options struct {
4 | MetricsEnabled bool
5 | }
6 |
7 | func DefaultOptions() *Options {
8 | return &Options{
9 | MetricsEnabled: true,
10 | }
11 | }
12 |
13 | func (o *Options) Validate() error {
14 | return nil
15 | }
16 |
17 | func (o *Options) WithMetricsEnabled() *Options {
18 | o.MetricsEnabled = true
19 |
20 | return o
21 | }
22 |
23 | func (o *Options) WithMetricsDisabled() *Options {
24 | o.MetricsEnabled = false
25 |
26 | return o
27 | }
28 |
29 | func (o *Options) SetMetricsEnabled(enabled bool) *Options {
30 | o.MetricsEnabled = enabled
31 |
32 | return o
33 | }
34 |
--------------------------------------------------------------------------------
/pkg/forky/db/option.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | type Options struct {
4 | MetricsEnabled bool
5 | }
6 |
7 | func DefaultOptions() *Options {
8 | return &Options{
9 | MetricsEnabled: true,
10 | }
11 | }
12 |
13 | func (o *Options) Validate() error {
14 | return nil
15 | }
16 |
17 | func (o *Options) WithMetricsEnabled() *Options {
18 | o.MetricsEnabled = true
19 |
20 | return o
21 | }
22 |
23 | func (o *Options) WithMetricsDisabled() *Options {
24 | o.MetricsEnabled = false
25 |
26 | return o
27 | }
28 |
29 | func (o *Options) SetMetricsEnabled(enabled bool) *Options {
30 | o.MetricsEnabled = enabled
31 |
32 | return o
33 | }
34 |
--------------------------------------------------------------------------------
/pkg/forky/service/config.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "github.com/ethpandaops/forky/pkg/forky/db"
5 | "github.com/ethpandaops/forky/pkg/forky/ethereum"
6 | "github.com/ethpandaops/forky/pkg/forky/human"
7 | "github.com/ethpandaops/forky/pkg/forky/source"
8 | "github.com/ethpandaops/forky/pkg/forky/store"
9 | )
10 |
11 | type Config struct {
12 | Sources []source.Config `yaml:"sources"`
13 |
14 | Store store.Config `yaml:"store"`
15 |
16 | Indexer db.IndexerConfig `yaml:"indexer"`
17 |
18 | RetentionPeriod human.Duration `yaml:"retention_period" default:"24h"`
19 |
20 | Ethereum ethereum.Config `yaml:"ethereum"`
21 | }
22 |
--------------------------------------------------------------------------------
/pkg/forky/store/option.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | type Options struct {
4 | MetricsEnabled bool
5 | }
6 |
7 | func DefaultOptions() *Options {
8 | return &Options{
9 | MetricsEnabled: true,
10 | }
11 | }
12 |
13 | func (o *Options) Validate() error {
14 | return nil
15 | }
16 |
17 | func (o *Options) WithMetricsEnabled() *Options {
18 | o.MetricsEnabled = true
19 |
20 | return o
21 | }
22 |
23 | func (o *Options) WithMetricsDisabled() *Options {
24 | o.MetricsEnabled = false
25 |
26 | return o
27 | }
28 |
29 | func (o *Options) SetMetricsEnabled(enabled bool) *Options {
30 | o.MetricsEnabled = enabled
31 |
32 | return o
33 | }
34 |
--------------------------------------------------------------------------------
/pkg/forky/service/option.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | type Options struct {
4 | MetricsEnabled bool
5 | }
6 |
7 | func DefaultOptions() *Options {
8 | return &Options{
9 | MetricsEnabled: true,
10 | }
11 | }
12 |
13 | func (o *Options) Validate() error {
14 | return nil
15 | }
16 |
17 | func (o *Options) WithMetricsEnabled() *Options {
18 | o.MetricsEnabled = true
19 |
20 | return o
21 | }
22 |
23 | func (o *Options) WithMetricsDisabled() *Options {
24 | o.MetricsEnabled = false
25 |
26 | return o
27 | }
28 |
29 | func (o *Options) SetMetricsEnabled(enabled bool) *Options {
30 | o.MetricsEnabled = enabled
31 |
32 | return o
33 | }
34 |
--------------------------------------------------------------------------------
/web/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
4 | theme: {
5 | extend: {
6 | boxShadow: {
7 | 'inner-lg': '0px 0px 5px 1px rgba(0,0,0,0.5) inset',
8 | 'inner-xl': '0px 0px 10px 2px rgba(0,0,0,0.5) inset',
9 | },
10 | keyframes: {
11 | 'pulse-light': {
12 | '50%': { opacity: 0.97 },
13 | },
14 | },
15 | animation: {
16 | 'pulse-light': 'pulse-light 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
17 | },
18 | },
19 | },
20 | plugins: [],
21 | darkMode: 'class',
22 | };
23 |
--------------------------------------------------------------------------------
/web/vite.config.ts:
--------------------------------------------------------------------------------
1 | import react from '@vitejs/plugin-react';
2 | import { defineConfig } from 'vite';
3 | import tailwindcss from '@tailwindcss/vite';
4 | import tsconfigPaths from 'vite-tsconfig-paths';
5 |
6 | export default defineConfig({
7 | plugins: [react(), tsconfigPaths(), tailwindcss()],
8 | build: {
9 | outDir: 'build/frontend',
10 | },
11 | server: {
12 | proxy: {
13 | '/api': {
14 | target: 'https://forky.mainnet.ethpandaops.io',
15 | changeOrigin: true,
16 | secure: false,
17 | },
18 | // '/api': {
19 | // target: 'http://localhost:5555',
20 | // },
21 | },
22 | },
23 | });
24 |
--------------------------------------------------------------------------------
/web/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import tsconfigPaths from 'vite-tsconfig-paths';
2 | import { defineConfig, mergeConfig } from 'vitest/config';
3 | import GithubActionsReporter from 'vitest-github-actions-reporter';
4 | import viteConfig from './vite.config.js';
5 |
6 | export default mergeConfig(
7 | viteConfig,
8 | defineConfig({
9 | plugins: [tsconfigPaths()],
10 | test: {
11 | globals: true,
12 | environment: 'jsdom',
13 | setupFiles: 'vitest.setup.ts',
14 | reporters: process.env.GITHUB_ACTIONS
15 | ? ['default', new GithubActionsReporter()]
16 | : ['default'],
17 | coverage: {},
18 | },
19 | }),
20 | );
21 |
--------------------------------------------------------------------------------
/pkg/forky/db/frame_metadata_label.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "time"
5 |
6 | "gorm.io/gorm"
7 | )
8 |
9 | type FrameMetadataLabel struct {
10 | gorm.Model
11 | Name string `gorm:"index:idx_name_created_at_deleted_at,where:deleted_at IS NULL"`
12 | FrameID string `gorm:"index"`
13 | CreatedAt time.Time `gorm:"index:idx_name_created_at_deleted_at,where:deleted_at IS NULL"`
14 | }
15 |
16 | type FrameMetadataLabels []FrameMetadataLabel
17 |
18 | func (f *FrameMetadataLabels) AsStrings() []string {
19 | labels := make([]string, len(*f))
20 |
21 | for i := range *f {
22 | labels[i] = (*f)[i].Name
23 | }
24 |
25 | return labels
26 | }
27 |
--------------------------------------------------------------------------------
/web/src/components/Loading.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'clsx';
2 |
3 | import Logo from '@assets/forky_logo.png';
4 |
5 | export default function Loading({
6 | message,
7 | className,
8 | textColor,
9 | }: {
10 | message: string;
11 | className?: string;
12 | textColor?: string;
13 | }) {
14 | return (
15 |
22 |

23 |
{message}
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/web/src/hooks/useWindowSize.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 |
3 | export default function useWindowSize() {
4 | const [windowSize, setWindowSize] = useState<{ width: number; height: number }>({
5 | width: window.innerWidth,
6 | height: window.innerHeight,
7 | });
8 |
9 | useEffect(() => {
10 | function handleResize() {
11 | setWindowSize({
12 | width: window.innerWidth,
13 | height: window.innerHeight,
14 | });
15 | }
16 |
17 | window.addEventListener('resize', handleResize);
18 |
19 | return () => window.removeEventListener('resize', handleResize);
20 | }, []);
21 |
22 | return [windowSize.width, windowSize.height];
23 | }
24 |
--------------------------------------------------------------------------------
/pkg/forky/service/pagination.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import "github.com/ethpandaops/forky/pkg/forky/db"
4 |
5 | type PaginationCursor struct {
6 | // The cursor to start from.
7 | Offset int `json:"offset"`
8 | // The number of items to return.
9 | Limit int `json:"limit"`
10 | }
11 |
12 | func DefaultPagination() *PaginationCursor {
13 | return &PaginationCursor{
14 | Offset: 0,
15 | Limit: 1000,
16 | }
17 | }
18 |
19 | func (p *PaginationCursor) AsDBPageCursor() *db.PaginationCursor {
20 | return &db.PaginationCursor{
21 | Offset: p.Offset,
22 | Limit: p.Limit,
23 | }
24 | }
25 |
26 | type PaginationResponse struct {
27 | // The total number of items.
28 | Total int64 `json:"total"`
29 | }
30 |
--------------------------------------------------------------------------------
/web/src/utils/strings.ts:
--------------------------------------------------------------------------------
1 | export function truncateHash(hash?: string): string {
2 | if (!hash) return '';
3 | if (hash.length <= 10) return hash;
4 | return hash.substring(0, 6) + '...' + hash.substring(hash.length - 4, hash.length);
5 | }
6 |
7 | export function convertToHexColor(str: string): string {
8 | let hash = 0;
9 | for (let i = 0; i < str.length; i++) {
10 | hash = str.charCodeAt(i) + ((hash << 5) - hash);
11 | hash |= hash; // Use a bitwise OR to simplify the code
12 | }
13 | let color = '#';
14 | for (let i = 0; i < 3; i++) {
15 | const value = (hash >> (i * 8)) & 255;
16 | color += value.toString(16).padStart(2, '0'); // Use padStart to ensure 2 characters
17 | }
18 | return color;
19 | }
20 |
--------------------------------------------------------------------------------
/pkg/forky/db/pagination.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import "gorm.io/gorm"
4 |
5 | type PaginationCursor struct {
6 | // The cursor to start from.
7 | Offset int `json:"offset"`
8 | // The number of items to return.
9 | Limit int `json:"limit"`
10 | // OrderBy is the column to order by.
11 | OrderBy string `json:"order_by"`
12 | }
13 |
14 | func (p *PaginationCursor) ApplyOffsetLimit(query *gorm.DB) *gorm.DB {
15 | if p.Limit != 0 {
16 | query = query.Limit(p.Limit)
17 | }
18 |
19 | return query.Offset(p.Offset)
20 | }
21 |
22 | func (p *PaginationCursor) ApplyOrderBy(query *gorm.DB) *gorm.DB {
23 | if p.OrderBy != "" {
24 | query = query.Order(p.OrderBy)
25 | } else {
26 | query = query.Order("fetched_at ASC")
27 | }
28 |
29 | return query
30 | }
31 |
--------------------------------------------------------------------------------
/pkg/forky/service/operations.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | type Operation string
4 |
5 | const (
6 | OperationAddFrame Operation = "add_frame"
7 | OperationGetFrame Operation = "get_frame"
8 | OperationDeleteFrame Operation = "delete_frame"
9 |
10 | OperationListMetadata Operation = "list_metadata"
11 | OperationUpdateMetadata Operation = "update_metadata"
12 |
13 | OperationListNodes Operation = "list_nodes"
14 | OperationListSlots Operation = "list_slots"
15 | OperationListEpochs Operation = "list_epochs"
16 | OperationListLabels Operation = "list_labels"
17 |
18 | OperationGetEthereumNow Operation = "get_ethereum_now"
19 | OperationGetEthereumSpec Operation = "get_ethereum_spec"
20 | OperationGetEthereumNetworkName Operation = "get_ethereum_network_name"
21 | )
22 |
--------------------------------------------------------------------------------
/pkg/forky/api/config.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "github.com/ethpandaops/forky/pkg/forky/human"
5 | "github.com/pkg/errors"
6 | )
7 |
8 | type Config struct {
9 | EdgeCacheConfig EdgeCacheConfig `yaml:"edge_cache" default:"{}"`
10 | }
11 |
12 | type EdgeCacheConfig struct {
13 | Enabled bool `yaml:"enabled" default:"true"`
14 |
15 | FrameTTL human.Duration `yaml:"frame_ttl" default:"1440m"`
16 | }
17 |
18 | func (c *Config) Validate() error {
19 | if err := c.EdgeCacheConfig.Validate(); err != nil {
20 | return errors.Wrap(err, "invalid edge cache config")
21 | }
22 |
23 | return nil
24 | }
25 |
26 | func (c *EdgeCacheConfig) Validate() error {
27 | if c.Enabled && c.FrameTTL.Duration == 0 {
28 | return errors.New("frame_ttl must be greater than 0")
29 | }
30 |
31 | return nil
32 | }
33 |
--------------------------------------------------------------------------------
/pkg/forky/human/duration.go:
--------------------------------------------------------------------------------
1 | package human
2 |
3 | import (
4 | "encoding/json"
5 | "time"
6 | )
7 |
8 | type Duration struct {
9 | time.Duration
10 | }
11 |
12 | func (d *Duration) UnmarshalText(text []byte) error {
13 | return d.Unmarshal(string(text))
14 | }
15 |
16 | func (d *Duration) UnmarshalJSON(data []byte) error {
17 | var s string
18 | if err := json.Unmarshal(data, &s); err != nil {
19 | return err
20 | }
21 |
22 | return d.Unmarshal(s)
23 | }
24 |
25 | func (d *Duration) Unmarshal(s string) (err error) {
26 | d.Duration, err = time.ParseDuration(s)
27 |
28 | return
29 | }
30 |
31 | func (d Duration) MarshalText() ([]byte, error) {
32 | return []byte(d.Duration.String()), nil
33 | }
34 |
35 | func (d Duration) MarshalJSON() ([]byte, error) {
36 | return json.Marshal(d.Duration.String())
37 | }
38 |
--------------------------------------------------------------------------------
/pkg/forky/source/option.go:
--------------------------------------------------------------------------------
1 | package source
2 |
3 | type Options struct {
4 | MetricsEnabled bool
5 | AllowedEthereumNetworks []string
6 | }
7 |
8 | func DefaultOptions() *Options {
9 | return &Options{
10 | MetricsEnabled: true,
11 | AllowedEthereumNetworks: []string{},
12 | }
13 | }
14 |
15 | func (o *Options) Validate() error {
16 | return nil
17 | }
18 |
19 | func (o *Options) WithMetricsEnabled() *Options {
20 | o.MetricsEnabled = true
21 |
22 | return o
23 | }
24 |
25 | func (o *Options) WithMetricsDisabled() *Options {
26 | o.MetricsEnabled = false
27 |
28 | return o
29 | }
30 |
31 | func (o *Options) SetMetricsEnabled(enabled bool) *Options {
32 | o.MetricsEnabled = enabled
33 |
34 | return o
35 | }
36 |
37 | func (o *Options) WithAllowedEthereumNetworks(networks []string) *Options {
38 | o.AllowedEthereumNetworks = networks
39 |
40 | return o
41 | }
42 |
--------------------------------------------------------------------------------
/web/src/hooks/useOutsideInteraction.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, RefObject, useCallback } from 'react';
2 |
3 | const useOutsideInteraction = (ref: RefObject, callback: () => void): void => {
4 | const handleInteractionOutside = useCallback(
5 | (event: MouseEvent | TouchEvent) => {
6 | if (ref.current && !ref.current.contains(event.target as Node)) {
7 | callback();
8 | }
9 | },
10 | [ref, callback],
11 | );
12 |
13 | useEffect(() => {
14 | document.addEventListener('mousedown', handleInteractionOutside);
15 | document.addEventListener('touchstart', handleInteractionOutside);
16 |
17 | return () => {
18 | document.removeEventListener('mousedown', handleInteractionOutside);
19 | document.removeEventListener('touchstart', handleInteractionOutside);
20 | };
21 | }, [handleInteractionOutside]);
22 | };
23 |
24 | export default useOutsideInteraction;
25 |
--------------------------------------------------------------------------------
/web/src/api/frames.ts:
--------------------------------------------------------------------------------
1 | import { Response, V1GetFrameResponse, Frame } from '@app/types/api';
2 | import { ProcessedData } from '@app/types/graph';
3 | import { BASE_URL } from '@utils/environment';
4 | import { processForkChoiceData } from '@utils/graph';
5 |
6 | export async function fetchFrame(id: string): Promise {
7 | const response = await fetch(`${BASE_URL}api/v1/frames/${id}`);
8 |
9 | if (!response.ok) {
10 | throw new Error('Failed to fetch snapshot data');
11 | }
12 | const json = (await response.json()) as Response;
13 | const frame = json.data?.frame;
14 |
15 | if (frame === undefined) throw new Error('No frame in response');
16 | if (frame?.data === undefined) throw new Error('No frame data in response');
17 | if (frame?.metadata === undefined) throw new Error('No frame metadata in response');
18 |
19 | return processForkChoiceData(frame as Required);
20 | }
21 |
--------------------------------------------------------------------------------
/pkg/forky/http.go:
--------------------------------------------------------------------------------
1 | package forky
2 |
3 | import (
4 | "net/http"
5 | )
6 |
7 | func wrapHandler(h http.Handler, fs http.FileSystem) http.HandlerFunc {
8 | return func(w http.ResponseWriter, r *http.Request) {
9 | nfrw := &NotFoundRedirectRespWr{ResponseWriter: w}
10 |
11 | h.ServeHTTP(nfrw, r)
12 |
13 | if nfrw.status == 404 {
14 | r.URL.Path = "/"
15 |
16 | w.Header().Set("Content-Type", "text/html")
17 |
18 | http.FileServer(fs).ServeHTTP(w, r)
19 | }
20 | }
21 | }
22 |
23 | type NotFoundRedirectRespWr struct {
24 | http.ResponseWriter
25 | status int
26 | }
27 |
28 | func (w *NotFoundRedirectRespWr) WriteHeader(status int) {
29 | w.status = status
30 |
31 | if status != http.StatusNotFound {
32 | w.ResponseWriter.WriteHeader(status)
33 | }
34 | }
35 |
36 | func (w *NotFoundRedirectRespWr) Write(p []byte) (int, error) {
37 | if w.status != http.StatusNotFound {
38 | return w.ResponseWriter.Write(p)
39 | }
40 |
41 | return len(p), nil
42 | }
43 |
--------------------------------------------------------------------------------
/web/src/components/Icon.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react';
2 |
3 | import classNames from 'clsx';
4 |
5 | export default function Icon({
6 | className,
7 | text,
8 | size = 'sm',
9 | icon,
10 | }: {
11 | className?: string;
12 | text?: string;
13 | size?: 'sm' | 'md' | 'lg';
14 | icon: (className: string) => ReactNode;
15 | }) {
16 | return (
17 |
27 | {icon(
28 | classNames(
29 | size === 'sm' && 'h-6 w-6',
30 | size === 'md' && 'h-8 w-8',
31 | size === 'lg' && 'h-10 w-10',
32 | ),
33 | )}
34 | {text && {text}}
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/web/src/components/Edge.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 |
3 | import classNames from 'clsx';
4 |
5 | function Edge({
6 | x1,
7 | x2,
8 | y1,
9 | y2,
10 | thickness,
11 | className,
12 | }: {
13 | x1: number;
14 | x2: number;
15 | y1: number;
16 | y2: number;
17 | thickness: number;
18 | className?: string;
19 | }) {
20 | const length = Math.sqrt((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1));
21 |
22 | const cx = (x1 + x2) / 2 - length / 2;
23 | const cy = (y1 + y2) / 2 - thickness / 2;
24 |
25 | const angle = Math.atan2(y1 - y2, x1 - x2) * (180 / Math.PI);
26 |
27 | const lineStyle = {
28 | height: thickness,
29 | left: `${cx}px`,
30 | top: `${cy}px`,
31 | width: `${length}px`,
32 | transform: `rotate(${angle}deg)`,
33 | };
34 |
35 | return (
36 |
40 | );
41 | }
42 |
43 | export default memo(Edge);
44 |
--------------------------------------------------------------------------------
/pkg/forky/source/metrics.go:
--------------------------------------------------------------------------------
1 | package source
2 |
3 | import "github.com/prometheus/client_golang/prometheus"
4 |
5 | type BasicMetrics struct {
6 | namespace string
7 |
8 | itemsFetched *prometheus.CounterVec
9 | sourceType string
10 | sourceName string
11 | }
12 |
13 | func NewBasicMetrics(namespace, sourceType, sourceName string, enabled bool) *BasicMetrics {
14 | m := &BasicMetrics{
15 | namespace: namespace,
16 | sourceType: sourceType,
17 | itemsFetched: prometheus.NewCounterVec(prometheus.CounterOpts{
18 | Namespace: namespace,
19 | Name: "items_fetched_count",
20 | Help: "The amount of items fetched by the source",
21 | }, []string{"source_type", "source_name", "type"}),
22 | }
23 |
24 | if enabled {
25 | //nolint:errcheck // We can only register metrics once.
26 | prometheus.Register(m.itemsFetched)
27 | }
28 |
29 | return m
30 | }
31 |
32 | func (m *BasicMetrics) ObserveItemFetched(itemType string) {
33 | m.itemsFetched.WithLabelValues(m.sourceType, m.sourceName, itemType).Inc()
34 | }
35 |
--------------------------------------------------------------------------------
/pkg/forky/service/purger.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "context"
5 | "time"
6 |
7 | "github.com/ethpandaops/forky/pkg/forky/db"
8 | )
9 |
10 | func (f *ForkChoice) DeleteOldFrames(ctx context.Context) error {
11 | // Get all frames that are outside of the retention period
12 | // and delete them.
13 | before := time.Now().Add(-f.config.RetentionPeriod.Duration)
14 |
15 | filter := &FrameFilter{
16 | Before: &before,
17 | }
18 |
19 | frames, err := f.indexer.ListFrameMetadata(ctx, filter.AsDBFilter(), &db.PaginationCursor{
20 | Limit: 1000,
21 | Offset: 0,
22 | OrderBy: "fetched_at ASC",
23 | })
24 | if err != nil {
25 | return err
26 | }
27 |
28 | for _, frame := range frames {
29 | f.log.WithField("frame_id", frame.ID).WithField("age", time.Since(frame.FetchedAt)).Debug("Deleting old frame")
30 |
31 | if err := f.DeleteFrame(ctx, frame.ID); err != nil {
32 | f.log.WithError(err).WithField("frame_id", frame.ID).Error("Failed to delete old frame")
33 | }
34 | }
35 |
36 | f.log.Debugf("Deleted %v old frames", len(frames))
37 |
38 | return nil
39 | }
40 |
--------------------------------------------------------------------------------
/pkg/forky/ethereum/client.go:
--------------------------------------------------------------------------------
1 | package ethereum
2 |
3 | import (
4 | "strings"
5 | )
6 |
7 | type Client string
8 |
9 | const (
10 | ClientUnknown Client = "unknown"
11 | ClientLighthouse Client = "lighthouse"
12 | ClientNimbus Client = "nimbus"
13 | ClientTeku Client = "teku"
14 | ClientPrysm Client = "prysm"
15 | ClientLodestar Client = "lodestar"
16 | )
17 |
18 | var AllClients = []Client{
19 | ClientUnknown,
20 | ClientLighthouse,
21 | ClientNimbus,
22 | ClientTeku,
23 | ClientPrysm,
24 | ClientLodestar,
25 | }
26 |
27 | func ClientFromString(client string) Client {
28 | asLower := strings.ToLower(client)
29 |
30 | if strings.Contains(asLower, "lighthouse") {
31 | return ClientLighthouse
32 | }
33 |
34 | if strings.Contains(asLower, "nimbus") {
35 | return ClientNimbus
36 | }
37 |
38 | if strings.Contains(asLower, "teku") {
39 | return ClientTeku
40 | }
41 |
42 | if strings.Contains(asLower, "prysm") {
43 | return ClientPrysm
44 | }
45 |
46 | if strings.Contains(asLower, "lodestar") {
47 | return ClientLodestar
48 | }
49 |
50 | return ClientUnknown
51 | }
52 |
--------------------------------------------------------------------------------
/web/src/providers/application.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react';
2 |
3 | import { navigate } from 'wouter/use-browser-location';
4 |
5 | import EthereumProvider, { Props as EthereumProps } from '@providers/ethereum';
6 | import FocusProvider, { Props as FocusProps } from '@providers/focus';
7 | import SelectionProvider, { Props as SelectionProps } from '@providers/selection';
8 |
9 | interface Props {
10 | children: ReactNode;
11 | ethereum: Omit;
12 | focus: Omit;
13 | selection?: Omit;
14 | }
15 |
16 | function Provider({ children, ethereum, focus, selection }: Props) {
17 | // clear the time param if it exists
18 | if (new URLSearchParams(window.location.search).get('t')) {
19 | navigate(window.location.pathname, { replace: true });
20 | }
21 | return (
22 |
23 |
24 | {children}
25 |
26 |
27 | );
28 | }
29 |
30 | export default Provider;
31 |
--------------------------------------------------------------------------------
/web/src/components/ConcatNode.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'clsx';
2 |
3 | function ConcatNode({
4 | id,
5 | slotStart,
6 | slotEnd,
7 | x,
8 | y,
9 | radius,
10 | className,
11 | }: {
12 | id?: string;
13 | slotStart: number;
14 | slotEnd: number;
15 | x: number;
16 | y: number;
17 | radius: number;
18 | className?: string;
19 | }) {
20 | return (
21 |
36 |
CONCAT
37 |
38 | {slotStart} → {slotEnd}
39 |
40 |
41 | );
42 | }
43 |
44 | export default ConcatNode;
45 |
--------------------------------------------------------------------------------
/web/src/ErrorBoundary.tsx:
--------------------------------------------------------------------------------
1 | import { Component, ErrorInfo, ReactNode } from 'react';
2 |
3 | import Logo from '@assets/forky_logo.png';
4 |
5 | interface Props {
6 | children?: ReactNode;
7 | }
8 |
9 | interface State {
10 | hasError: boolean;
11 | }
12 |
13 | class ErrorBoundary extends Component {
14 | public state: State = {
15 | hasError: false,
16 | };
17 |
18 | public static getDerivedStateFromError(): State {
19 | // Update state so the next render will show the fallback UI.
20 | return { hasError: true };
21 | }
22 |
23 | public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
24 | console.error('Uncaught error:', error, errorInfo);
25 | }
26 |
27 | public render() {
28 | if (this.state.hasError) {
29 | return (
30 |
31 |

32 |
Uhh... Something went wrong
33 |
34 | );
35 | }
36 |
37 | return this.props.children;
38 | }
39 | }
40 |
41 | export default ErrorBoundary;
42 |
--------------------------------------------------------------------------------
/web/src/components/Download.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowDownTrayIcon } from '@heroicons/react/20/solid';
2 | import classNames from 'clsx';
3 |
4 | export default function Download({
5 | data,
6 | filename,
7 | className,
8 | type = 'text/json',
9 | text,
10 | size = 'sm',
11 | }: {
12 | data: string;
13 | filename: string;
14 | className?: string;
15 | type?: string;
16 | text?: string;
17 | size?: 'sm' | 'md' | 'lg';
18 | }) {
19 | return (
20 |
32 |
39 | {text && {text}}
40 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/pkg/forky/db/operation.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | type Operation string
4 |
5 | const (
6 | OperationInsertFrameMetadata Operation = "insert_frame_metadata"
7 | OperationDeleteFrameMetadata Operation = "delete_frame_metadata"
8 | OperationCountFrameMetadata Operation = "count_frame_metadata"
9 | OperationListFrameMetadata Operation = "list_frame_metadata"
10 | OperationUpdateFrameMetadata Operation = "update_frame_metadata"
11 |
12 | OperationCountNodesWithFrames Operation = "count_nodes_with_frames"
13 | OperationsListNodesWithFrames Operation = "list_nodes_with_frames"
14 |
15 | OperationCountSlotsWithFrames Operation = "count_slots_with_frames"
16 | OperationListSlotsWithFrames Operation = "list_slots_with_frames"
17 |
18 | OperationCountEpochsWithFrames Operation = "count_epochs_with_frames"
19 | OperationListEpochsWithFrames Operation = "list_epochs_with_frames"
20 |
21 | OperationCountLabelsWithFrames Operation = "count_labels_with_frames"
22 | OperationListLabelsWithFrames Operation = "list_labels_with_frames"
23 |
24 | OperationDeleteFrameMetadataLabelsByIDs Operation = "delete_frame_metadata_labels_by_ids"
25 | OperationDeleteFrameMetadataLabelsByName Operation = "delete_frame_metadata_labels_by_name"
26 | )
27 |
--------------------------------------------------------------------------------
/web/src/parts/Timeline.tsx:
--------------------------------------------------------------------------------
1 | import { MinusIcon } from '@heroicons/react/20/solid';
2 |
3 | import Control from '@components/Control';
4 | import EpochDial from '@components/EpochDial';
5 | import SlotDial from '@components/SlotDial';
6 |
7 | export default function Timeline() {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/pkg/forky/types/event_source.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import "math/rand"
4 |
5 | type EventSource string
6 |
7 | const (
8 | NilEventSource EventSource = ""
9 | UnknownEventSource EventSource = "unknown"
10 | BeaconNodeEventSource EventSource = "beacon_node"
11 | XatuPollingEventSource EventSource = "xatu_polling"
12 | XatuReorgEventEventSource EventSource = "xatu_reorg_event"
13 | )
14 |
15 | func NewEventSourceFromString(s string) EventSource {
16 | switch s {
17 | case string(UnknownEventSource):
18 | return UnknownEventSource
19 | case string(BeaconNodeEventSource):
20 | return BeaconNodeEventSource
21 | case string(XatuPollingEventSource):
22 | return XatuPollingEventSource
23 | case string(XatuReorgEventEventSource):
24 | return XatuReorgEventEventSource
25 | default:
26 | return NilEventSource
27 | }
28 | }
29 |
30 | func RandomEventSource() EventSource {
31 | //nolint:gosec // Not concerned about randomness here.
32 | switch rand.Intn(3) {
33 | case 0:
34 | return BeaconNodeEventSource
35 | case 1:
36 | return XatuPollingEventSource
37 | case 2:
38 | return XatuReorgEventEventSource
39 | default:
40 | return UnknownEventSource
41 | }
42 | }
43 |
44 | func (e EventSource) String() string {
45 | return string(e)
46 | }
47 |
--------------------------------------------------------------------------------
/.golangci.yml:
--------------------------------------------------------------------------------
1 | linters-settings:
2 | errcheck:
3 | check-type-assertions: true
4 | goconst:
5 | min-len: 2
6 | min-occurrences: 3
7 | gocritic:
8 | enabled-tags:
9 | - diagnostic
10 | - experimental
11 | - opinionated
12 | - performance
13 | - style
14 | govet:
15 | shadow: true
16 | nolintlint:
17 | require-explanation: true
18 | require-specific: true
19 |
20 | linters:
21 | disable-all: true
22 | enable:
23 | - asasalint
24 | - bidichk
25 | - bodyclose
26 | - containedctx
27 | - decorder
28 | - dogsled
29 | - durationcheck
30 | - errcheck
31 | - errname
32 | - copyloopvar
33 | - gocritic
34 | - gocyclo
35 | - gofmt
36 | - goheader
37 | - goimports
38 | - gosec
39 | - gosimple
40 | - govet
41 | - ineffassign
42 | - misspell
43 | - nakedret
44 | - nilerr
45 | - nilerr
46 | - nilnil
47 | - nlreturn
48 | - nolintlint
49 | - nosprintfhostport
50 | - prealloc
51 | - predeclared
52 | - promlinter
53 | - reassign
54 | - staticcheck
55 | - stylecheck
56 | - thelper
57 | - tparallel
58 | - typecheck
59 | - unconvert
60 | - unused
61 | - whitespace
62 | - wsl
63 |
64 | run:
65 | issues-exit-code: 1
66 |
--------------------------------------------------------------------------------
/pkg/forky/ethereum/config.go:
--------------------------------------------------------------------------------
1 | package ethereum
2 |
3 | import (
4 | "github.com/pkg/errors"
5 | )
6 |
7 | type Config struct {
8 | Network NetworkConfig `yaml:"network"`
9 | }
10 |
11 | type NetworkConfig struct {
12 | Name string `yaml:"name"`
13 | Spec SpecConfig `yaml:"spec"`
14 | }
15 |
16 | type SpecConfig struct {
17 | SecondsPerSlot uint64 `yaml:"seconds_per_slot"`
18 | SlotsPerEpoch uint64 `yaml:"slots_per_epoch"`
19 | GenesisTime uint64 `yaml:"genesis_time"`
20 | }
21 |
22 | func (c *Config) Validate() error {
23 | if err := c.Network.Validate(); err != nil {
24 | return errors.Wrap(err, "invalid network config")
25 | }
26 |
27 | return nil
28 | }
29 |
30 | func (c *NetworkConfig) Validate() error {
31 | if c.Name == "" {
32 | return errors.New("name is required")
33 | }
34 |
35 | if err := c.Spec.Validate(); err != nil {
36 | return errors.Wrap(err, "invalid spec config")
37 | }
38 |
39 | return nil
40 | }
41 |
42 | func (c *SpecConfig) Validate() error {
43 | if c.SecondsPerSlot == 0 {
44 | return errors.New("seconds_per_slot is required")
45 | }
46 |
47 | if c.SlotsPerEpoch == 0 {
48 | return errors.New("slots_per_epoch is required")
49 | }
50 |
51 | if c.GenesisTime == 0 {
52 | return errors.New("genesis_time is required")
53 | }
54 |
55 | return nil
56 | }
57 |
--------------------------------------------------------------------------------
/pkg/forky/db/event_source.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import "github.com/ethpandaops/forky/pkg/forky/types"
4 |
5 | type EventSource int
6 |
7 | const (
8 | NilEventSource EventSource = iota
9 | UnknownEventSource
10 | BeaconNodeEventSource
11 | XatuPollingEventSource
12 | XatuReorgEventEventSource
13 | )
14 |
15 | func NewEventSourceFromString(s string) EventSource {
16 | return NewEventSourceFromType(types.NewEventSourceFromString(s))
17 | }
18 |
19 | func NewEventSourceFromType(s types.EventSource) EventSource {
20 | switch s {
21 | case types.UnknownEventSource:
22 | return UnknownEventSource
23 | case types.BeaconNodeEventSource:
24 | return BeaconNodeEventSource
25 | case types.XatuPollingEventSource:
26 | return XatuPollingEventSource
27 | case types.XatuReorgEventEventSource:
28 | return XatuReorgEventEventSource
29 | default:
30 | return NilEventSource
31 | }
32 | }
33 |
34 | func NewEventSource(i int) EventSource {
35 | switch i {
36 | case 1:
37 | return UnknownEventSource
38 | case 2:
39 | return BeaconNodeEventSource
40 | case 3:
41 | return XatuPollingEventSource
42 | case 4:
43 | return XatuReorgEventEventSource
44 | default:
45 | return NilEventSource
46 | }
47 | }
48 |
49 | func (e EventSource) String() string {
50 | return [...]string{"", "unknown", "beacon_node", "xatu_polling", "xatu_reorg_event"}[e]
51 | }
52 |
--------------------------------------------------------------------------------
/web/src/utils/tailwind.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * Technically, tailwind config could be imported and the colors be extracted from there.
3 | * However this adds ~100kb to the production bundle size, so we'll just hardcode them here
4 | * for colors that are used in canvas/webgl components.
5 | *
6 | * https://tailwindcss.com/docs/configuration#referencing-in-java-script
7 | */
8 |
9 | export const colors = {
10 | stone: {
11 | 50: '#fafaf9',
12 | 600: '#57534e',
13 | 700: '#44403c',
14 | 800: '#292524',
15 | 900: '#1c1917',
16 | },
17 | emerald: {
18 | 600: '#059669',
19 | 800: '#065f46',
20 | },
21 | amber: {
22 | 600: '#d97706',
23 | 800: '#92400e',
24 | },
25 | indigo: {
26 | 600: '#4f46e5',
27 | 800: '#3730a3',
28 | },
29 | fuchsia: {
30 | 600: '#c026d3',
31 | 800: '#86198f',
32 | },
33 | rose: {
34 | 600: '#e11d48',
35 | 800: '#9f1239',
36 | },
37 | };
38 |
39 | export const fonts = {
40 | mono: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
41 | sans: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"',
42 | serif: 'ui-serif, Georgia, Cambria, "Times New Roman", Times, serif',
43 | };
44 |
--------------------------------------------------------------------------------
/web/src/api/metadata.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Response,
3 | FrameMetaData,
4 | V1MetadataListRequest,
5 | V1MetadataListResponse,
6 | V1MetadataListNodesRequest,
7 | V1MetadataListNodesResponse,
8 | } from '@app/types/api';
9 | import { BASE_URL } from '@utils/environment';
10 |
11 | export async function fetchMetadataNodes(payload: V1MetadataListNodesRequest): Promise {
12 | const response = await fetch(`${BASE_URL}api/v1/metadata/nodes`, {
13 | method: 'POST',
14 | headers: {
15 | 'Content-Type': 'application/json',
16 | },
17 | body: JSON.stringify(payload),
18 | });
19 |
20 | if (!response.ok) {
21 | throw new Error('Failed to fetch metadata nodes');
22 | }
23 |
24 | const json = (await response.json()) as Response;
25 | return json.data?.nodes || [];
26 | }
27 |
28 | export async function fetchMetadataList(payload: V1MetadataListRequest): Promise {
29 | const response = await fetch(`${BASE_URL}api/v1/metadata`, {
30 | method: 'POST',
31 | headers: {
32 | 'Content-Type': 'application/json',
33 | },
34 | body: JSON.stringify(payload),
35 | });
36 |
37 | if (!response.ok) {
38 | throw new Error('Failed to fetch metadata list');
39 | }
40 | const json = (await response.json()) as Response;
41 | return json.data?.frames || [];
42 | }
43 |
--------------------------------------------------------------------------------
/web/src/hooks/__tests__/usePointer.test.ts:
--------------------------------------------------------------------------------
1 | import { renderHook, act } from '@testing-library/react';
2 |
3 | import usePointer, { PointerProps } from '@hooks/usePointer';
4 |
5 | describe('usePointer', () => {
6 | const pointerProps: PointerProps = {
7 | listen: true,
8 | };
9 |
10 | it('should update pointerState on mouse events', () => {
11 | const { result } = renderHook(() => usePointer(pointerProps));
12 |
13 | act(() => {
14 | const mouseMoveEvent = new MouseEvent('mousemove', { clientX: 100 });
15 | document.dispatchEvent(mouseMoveEvent);
16 | });
17 |
18 | expect(result.current).toEqual({
19 | x: 100,
20 | up: false,
21 | });
22 |
23 | act(() => {
24 | const mouseUpEvent = new MouseEvent('mouseup', { clientX: 100 });
25 | document.dispatchEvent(mouseUpEvent);
26 | });
27 |
28 | expect(result.current).toEqual({
29 | x: 100,
30 | up: true,
31 | });
32 | });
33 |
34 | it('should not update pointerState when listen is false', () => {
35 | const { result } = renderHook(() => usePointer({ listen: false }));
36 |
37 | act(() => {
38 | const mouseMoveEvent = new MouseEvent('mousemove', { clientX: 100 });
39 | document.dispatchEvent(mouseMoveEvent);
40 | });
41 |
42 | expect(result.current).toEqual({
43 | x: null,
44 | up: false,
45 | });
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/web/src/api/ethereum.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Response,
3 | V1GetEthereumNowResponse,
4 | V1GetEthereumSpecResponse,
5 | EthereumSpec,
6 | } from '@app/types/api';
7 | import { BASE_URL } from '@utils/environment';
8 |
9 | export type Spec = {
10 | network_name: string;
11 | spec: EthereumSpec;
12 | };
13 |
14 | export async function fetchNow(): Promise> {
15 | const response = await fetch(`${BASE_URL}api/v1/ethereum/now`);
16 |
17 | if (!response.ok) {
18 | throw new Error('Failed to fetch ethereum now');
19 | }
20 | const json = (await response.json()) as Response;
21 |
22 | if (!json.data?.slot) throw new Error('No slot in response');
23 | if (!json.data?.epoch) throw new Error('No epoch in response');
24 |
25 | return {
26 | slot: json.data?.slot,
27 | epoch: json.data?.epoch,
28 | };
29 | }
30 |
31 | export async function fetchSpec(): Promise> {
32 | const response = await fetch(`${BASE_URL}api/v1/ethereum/spec`);
33 |
34 | if (!response.ok) {
35 | throw new Error('Failed to fetch ethereum spec');
36 | }
37 | const json = (await response.json()) as Response;
38 |
39 | if (!json.data?.spec) throw new Error('No spec in response');
40 |
41 | return {
42 | network_name: json.data?.network_name ?? 'unknown',
43 | spec: json.data.spec,
44 | };
45 | }
46 |
--------------------------------------------------------------------------------
/web/src/hooks/__tests__/useWindowSize.test.ts:
--------------------------------------------------------------------------------
1 | import { act, renderHook } from '@testing-library/react';
2 |
3 | import useWindowSize from '@hooks/useWindowSize';
4 |
5 | describe('useWindowSize', () => {
6 | it('should return the initial window size', () => {
7 | const { result } = renderHook(() => useWindowSize());
8 |
9 | expect(result.current[0]).toEqual(window.innerWidth);
10 | expect(result.current[1]).toEqual(window.innerHeight);
11 | });
12 |
13 | it('should update the window size on resize event', async () => {
14 | Object.defineProperty(window, 'innerWidth', {
15 | writable: true,
16 | configurable: true,
17 | value: 1000,
18 | });
19 |
20 | Object.defineProperty(window, 'innerHeight', {
21 | writable: true,
22 | configurable: true,
23 | value: 800,
24 | });
25 |
26 | const { result } = renderHook(() => useWindowSize());
27 |
28 | Object.defineProperty(window, 'innerWidth', {
29 | writable: true,
30 | configurable: true,
31 | value: 1200,
32 | });
33 | Object.defineProperty(window, 'innerHeight', {
34 | writable: true,
35 | configurable: true,
36 | value: 900,
37 | });
38 |
39 | await act(async () => {
40 | global.dispatchEvent(new Event('resize'));
41 | });
42 |
43 | expect(result.current[0]).toEqual(1200);
44 | expect(result.current[1]).toEqual(900);
45 | });
46 | });
47 |
--------------------------------------------------------------------------------
/web/src/utils/functions.ts:
--------------------------------------------------------------------------------
1 | export function randomHex(size: number): string {
2 | let result = '0x';
3 | const characters = 'abcdef0123456789';
4 | const charactersLength = characters.length;
5 |
6 | for (let i = 0; i < size; i++) {
7 | result += characters.charAt(Math.floor(Math.random() * charactersLength));
8 | }
9 |
10 | return result;
11 | }
12 |
13 | export function randomInt(min: number, max: number): number {
14 | if (min > max) {
15 | throw new Error('Invalid range: min must be less than or equal to max');
16 | }
17 |
18 | return Math.floor(Math.random() * (max - min + 1)) + min;
19 | }
20 |
21 | export function randomBigInt(min: bigint, max: bigint): bigint {
22 | if (min > max) {
23 | throw new Error('Invalid range: min must be less than or equal to max');
24 | }
25 |
26 | const range = max - min + BigInt(1);
27 | const bitsNeeded = range.toString(2).length;
28 | let result: bigint;
29 |
30 | do {
31 | const randomBytes = new Uint8Array(Math.ceil(bitsNeeded / 8));
32 | crypto.getRandomValues(randomBytes);
33 |
34 | let binaryString = '';
35 | for (let i = 0; i < randomBytes.length; i++) {
36 | binaryString += randomBytes[i].toString(2).padStart(8, '0');
37 | }
38 |
39 | result = BigInt(`0b${binaryString}`) % range;
40 | } while (result >= range); // Retry if the result is out of range (extremely unlikely)
41 |
42 | return min + result;
43 | }
44 |
--------------------------------------------------------------------------------
/pkg/forky/config.go:
--------------------------------------------------------------------------------
1 | package forky
2 |
3 | import (
4 | "os"
5 |
6 | "github.com/creasty/defaults"
7 | "github.com/ethpandaops/forky/pkg/forky/api"
8 | "github.com/ethpandaops/forky/pkg/forky/service"
9 | "gopkg.in/yaml.v2"
10 | )
11 |
12 | type Config struct {
13 | ListenAddr string `yaml:"listen_addr" default:":5555"`
14 | LogLevel string `yaml:"log_level" default:"warn"`
15 | Metrics MetricsConfig `yaml:"metrics"`
16 | PProfAddr *string `yaml:"pprof_addr" default:":6060"`
17 |
18 | Forky *service.Config `yaml:"forky"`
19 |
20 | HTTP *api.Config `yaml:"http" default:"{}"`
21 | }
22 |
23 | type MetricsConfig struct {
24 | Enabled bool `yaml:"enabled" default:"true"`
25 | Addr string `yaml:"addr" default:":9090"`
26 | }
27 |
28 | func (c *Config) Validate() error {
29 | if err := c.HTTP.Validate(); err != nil {
30 | return err
31 | }
32 |
33 | return nil
34 | }
35 |
36 | func NewConfigFromYAML(y []byte) (*Config, error) {
37 | config := &Config{}
38 |
39 | if err := defaults.Set(config); err != nil {
40 | return nil, err
41 | }
42 |
43 | type plain Config
44 |
45 | if err := yaml.Unmarshal(y, (*plain)(config)); err != nil {
46 | return nil, err
47 | }
48 |
49 | return config, nil
50 | }
51 |
52 | func NewConfigFromYAMLFile(file string) (*Config, error) {
53 | yamlFile, err := os.ReadFile(file)
54 | if err != nil {
55 | return nil, err
56 | }
57 |
58 | return NewConfigFromYAML(yamlFile)
59 | }
60 |
--------------------------------------------------------------------------------
/pkg/forky/store/store.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | import (
4 | "context"
5 | "fmt"
6 |
7 | "github.com/ethpandaops/forky/pkg/forky/types"
8 | "github.com/ethpandaops/forky/pkg/yaml"
9 | "github.com/sirupsen/logrus"
10 | )
11 |
12 | // Store is an interface for different persistence implementations.
13 | type Store interface {
14 | // SaveFrame saves a frame to the store
15 | SaveFrame(ctx context.Context, frame *types.Frame) error
16 | // GetFrame fetches a frame from the store
17 | GetFrame(ctx context.Context, id string) (*types.Frame, error)
18 | // Delete deletes a frame from the store
19 | DeleteFrame(ctx context.Context, id string) error
20 | }
21 |
22 | func NewStore(namespace string, log logrus.FieldLogger, storeType Type, config yaml.RawMessage, opts *Options) (Store, error) {
23 | namespace += "_store"
24 |
25 | switch storeType {
26 | case FileSystemStoreType:
27 | var fsConfig FileSystemConfig
28 |
29 | if err := config.Unmarshal(&fsConfig); err != nil {
30 | return nil, err
31 | }
32 |
33 | return NewFileSystem(namespace, fsConfig, opts)
34 | case S3StoreType:
35 | var s3Config *S3StoreConfig
36 |
37 | if err := config.Unmarshal(&s3Config); err != nil {
38 | return nil, err
39 | }
40 |
41 | return NewS3Store(namespace, log, s3Config, opts)
42 | case MemoryStoreType:
43 | return NewMemoryStore(namespace, log, opts), nil
44 | default:
45 | return nil, fmt.Errorf("unknown store type: %s", storeType)
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/web/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
16 | Forky: Ethereum Beacon Chain Fork Choice Data Visualization & Analysis Tool
17 |
19 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/pkg/forky/service/forkchoice.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "errors"
5 | "time"
6 |
7 | "github.com/ethpandaops/forky/pkg/forky/db"
8 | )
9 |
10 | type SourceMetadata struct {
11 | Name string `json:"name"`
12 | Type string `json:"type"`
13 | }
14 |
15 | type FrameFilter struct {
16 | Node *string `json:"node"`
17 | Before *time.Time `json:"before"`
18 | After *time.Time `json:"after"`
19 | Slot *uint64 `json:"slot"`
20 | Epoch *uint64 `json:"epoch"`
21 | Labels *[]string `json:"labels"`
22 | ConsensusClient *string `json:"consensus_client"`
23 | EventSource *string `json:"event_source"`
24 | }
25 |
26 | func (f *FrameFilter) Validate() error {
27 | if f.Node == nil &&
28 | f.Before == nil &&
29 | f.After == nil &&
30 | f.Slot == nil &&
31 | f.Epoch == nil &&
32 | f.Labels == nil &&
33 | f.ConsensusClient == nil &&
34 | f.EventSource == nil {
35 | return errors.New("no filter specified")
36 | }
37 |
38 | return nil
39 | }
40 |
41 | func (f *FrameFilter) AsDBFilter() *db.FrameFilter {
42 | filter := &db.FrameFilter{
43 | Node: f.Node,
44 | Before: f.Before,
45 | After: f.After,
46 | Slot: f.Slot,
47 | Epoch: f.Epoch,
48 | Labels: f.Labels,
49 | ConsensusClient: f.ConsensusClient,
50 | }
51 |
52 | if f.EventSource != nil {
53 | es := int(db.NewEventSourceFromString(*f.EventSource))
54 |
55 | filter.EventSource = &es
56 | }
57 |
58 | return filter
59 | }
60 |
--------------------------------------------------------------------------------
/example_config.yaml:
--------------------------------------------------------------------------------
1 | listen_addr: ":5555"
2 | log_level: "debug"
3 | metrics:
4 | addr: ":9090"
5 | enabled: true
6 |
7 | http:
8 | edge_cache:
9 | enabled: true
10 |
11 | frame_ttl: 1440m
12 |
13 | forky:
14 | retention_period: "30m"
15 |
16 | store:
17 | type: memory
18 | config: {}
19 |
20 | # type: s3
21 | # config:
22 | # region: "us-east-1"
23 | # endpoint: http://localhost:9000
24 | # bucket_name: forkchoice
25 | # access_key: minioadmin
26 | # access_secret: minioadmin
27 |
28 | # type: fs
29 | # config:
30 | # base_dir: "/data/forky"
31 |
32 | indexer:
33 | dsn: "file::memory:?cache=shared"
34 | driver_name: sqlite
35 |
36 | # dsn: "postgres://user:secret@localhost:5432/mydatabasename"
37 | # driver_name: postgres
38 |
39 | sources:
40 | - name: "example"
41 | type: "beacon_node"
42 | config:
43 | address: "http://localhost:5052"
44 | polling_interval: "12s"
45 | labels:
46 | - "example_label"
47 |
48 | ethereum:
49 | network:
50 | # name: "mainnet"
51 | # spec:
52 | # seconds_per_slot: 12
53 | # slots_per_epoch: 32
54 | # genesis_time: 1606824023
55 |
56 | # name: "goerli"
57 | # spec:
58 | # seconds_per_slot: 12
59 | # slots_per_epoch: 32
60 | # genesis_time: 1616508000
61 |
62 | # name: "sepolia"
63 | # spec:
64 | # seconds_per_slot: 12
65 | # slots_per_epoch: 32
66 | # genesis_time: 1655733600
--------------------------------------------------------------------------------
/web/src/components/ModeToggle.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | import { MoonIcon, SunIcon } from '@heroicons/react/20/solid';
4 | import { hasDarkLocalStorage, hasDarkPreference } from '@utils/darkmode';
5 |
6 | export function ModeToggle() {
7 | useEffect(() => {
8 | if (hasDarkLocalStorage() || hasDarkPreference()) {
9 | document.documentElement.classList.add('dark');
10 | }
11 | }, []);
12 |
13 | function disableTransitionsTemporarily() {
14 | document.documentElement.classList.add('[&_*]:!transition-none');
15 | window.setTimeout(() => {
16 | document.documentElement.classList.remove('[&_*]:!transition-none');
17 | }, 0);
18 | }
19 |
20 | function toggleMode() {
21 | disableTransitionsTemporarily();
22 |
23 | const isDarkMode = document.documentElement.classList.toggle('dark');
24 | if (window.localStorage) {
25 | if (isDarkMode === hasDarkPreference()) {
26 | delete window.localStorage.isDarkMode;
27 | } else {
28 | window.localStorage.isDarkMode = isDarkMode;
29 | }
30 | }
31 | }
32 |
33 | return (
34 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/web/src/components/ProgressCircle.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 |
3 | interface ProcessCircleProps {
4 | progress: number;
5 | radius: number;
6 | className?: string;
7 | color: string;
8 | backgroundColor: string;
9 | }
10 |
11 | function ProcessCircle({
12 | progress,
13 | radius,
14 | className,
15 | backgroundColor,
16 | color,
17 | }: ProcessCircleProps) {
18 | const stroke = 16;
19 | const innerRadius = radius - stroke / 2 + 2;
20 | const circumference = innerRadius * 2 * Math.PI;
21 | const adjustedProgress = Math.min(Math.max(progress, 0), 100);
22 | const center = radius + stroke / 2;
23 |
24 | return (
25 |
52 | );
53 | }
54 |
55 | export default memo(ProcessCircle);
56 |
--------------------------------------------------------------------------------
/web/src/contexts/ethereum.tsx:
--------------------------------------------------------------------------------
1 | import { useContext as reactUseContext, createContext, useState } from 'react';
2 |
3 | export const Context = createContext(undefined);
4 |
5 | export default function useContext() {
6 | const context = reactUseContext(Context);
7 | if (context === undefined) {
8 | throw new Error('Ethereum context must be used within a Ethereum provider');
9 | }
10 | return context;
11 | }
12 |
13 | export interface State {
14 | secondsPerSlot: number;
15 | setSecondsPerSlot: (secondsPerSlot: number) => void;
16 | slotsPerEpoch: number;
17 | setSlotsPerEpoch: (slotsPerEpoch: number) => void;
18 | genesisTime: number;
19 | setGenesisTime: (genesisTime: number) => void;
20 | networkName: string;
21 | setNetworkName: (networkName: string) => void;
22 | }
23 |
24 | export interface ValueProps {
25 | secondsPerSlot: number;
26 | slotsPerEpoch: number;
27 | genesisTime: number;
28 | networkName: string;
29 | }
30 |
31 | export function useValue(props: ValueProps): State {
32 | const [secondsPerSlot, setSecondsPerSlot] = useState(props.secondsPerSlot);
33 | const [slotsPerEpoch, setSlotsPerEpoch] = useState(props.slotsPerEpoch);
34 | const [genesisTime, setGenesisTime] = useState(props.genesisTime);
35 | const [networkName, setNetworkName] = useState(props.networkName);
36 |
37 | return {
38 | secondsPerSlot,
39 | setSecondsPerSlot,
40 | slotsPerEpoch,
41 | setSlotsPerEpoch,
42 | genesisTime,
43 | setGenesisTime,
44 | networkName,
45 | setNetworkName,
46 | };
47 | }
48 |
--------------------------------------------------------------------------------
/web/src/parts/Stage.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | import { ProcessedData } from '@app/types/graph';
4 | import useFocus from '@contexts/focus';
5 | import useActive from '@hooks/useActive';
6 | import { useFrameQueries } from '@hooks/useQuery';
7 | import Graph from '@parts/Graph';
8 |
9 | export default function Stage() {
10 | const { ids } = useActive();
11 | const { byo, stop, byoData } = useFocus();
12 | const results = useFrameQueries(ids, !byo && ids.length > 0);
13 |
14 | useEffect(() => {
15 | if (byo) stop();
16 | }, [byo, stop]);
17 | useEffect(() => {
18 | if (byoData) stop();
19 | }, [byoData, stop]);
20 |
21 | const isLoading = !byo && results.every(result => result.isLoading);
22 | let data: { frames: ProcessedData[]; loadedIds: string[] } = {
23 | frames: byoData ? [byoData] : [],
24 | loadedIds: byoData ? [byoData.frame.metadata.id] : [],
25 | };
26 |
27 | if (!byo && !isLoading) {
28 | data = results.reduce<{ frames: ProcessedData[]; loadedIds: string[] }>(
29 | (acc, result) => {
30 | if (result.data) {
31 | acc.frames.push(result.data);
32 | acc.loadedIds.push(result.data.frame.metadata.id);
33 | }
34 | return acc;
35 | },
36 | { frames: [], loadedIds: [] },
37 | );
38 | }
39 |
40 | return (
41 |
45 | {!isLoading && }
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/pkg/forky/api/frames.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "net/http"
8 |
9 | "github.com/pkg/errors"
10 |
11 | fhttp "github.com/ethpandaops/forky/pkg/forky/api/http"
12 | "github.com/ethpandaops/forky/pkg/forky/service"
13 |
14 | "github.com/julienschmidt/httprouter"
15 | )
16 |
17 | type GetFramesBatchRequest struct {
18 | IDs []string `json:"ids"`
19 | }
20 |
21 | func (h *HTTP) handleV1GetFrame(ctx context.Context, _ *http.Request, p httprouter.Params, contentType fhttp.ContentType) (*fhttp.Response, error) {
22 | if err := fhttp.ValidateContentType(contentType, []fhttp.ContentType{fhttp.ContentTypeJSON}); err != nil {
23 | return fhttp.NewUnsupportedMediaTypeResponse(nil), err
24 | }
25 |
26 | id := p.ByName("id")
27 | if id == "" {
28 | return fhttp.NewBadRequestResponse(nil), errors.New("id is required")
29 | }
30 |
31 | frame, err := h.svc.GetFrame(ctx, id)
32 | if err != nil {
33 | if errors.Is(err, service.ErrFrameNotFound) {
34 | return fhttp.NewNotFoundResponse(nil), err
35 | }
36 |
37 | return fhttp.NewInternalServerErrorResponse(nil), err
38 | }
39 |
40 | rsp := fhttp.V1GetFrameResponse{
41 | Frame: frame,
42 | }
43 |
44 | response := fhttp.NewSuccessResponse(fhttp.ContentTypeResolvers{
45 | fhttp.ContentTypeJSON: func() ([]byte, error) {
46 | return json.Marshal(rsp)
47 | },
48 | })
49 |
50 | if h.config.EdgeCacheConfig.Enabled {
51 | response.SetCacheControl(fmt.Sprintf("public, max-age=%[1]v, s-maxage=%[1]v", h.config.EdgeCacheConfig.FrameTTL.Seconds()))
52 | }
53 |
54 | return response, nil
55 | }
56 |
--------------------------------------------------------------------------------
/cmd/root.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "context"
5 | "os"
6 |
7 | "github.com/ethpandaops/forky/pkg/forky"
8 | "github.com/sirupsen/logrus"
9 | "github.com/spf13/cobra"
10 | )
11 |
12 | // rootCmd represents the base command when called without any subcommands
13 | var rootCmd = &cobra.Command{
14 | Use: "forky",
15 | Short: "Fetches and serves Ethereum fork choice data",
16 | Run: func(cmd *cobra.Command, args []string) {
17 | cfg := initCommon()
18 | p := forky.NewServer(log, cfg)
19 | if err := p.Start(context.Background()); err != nil {
20 | log.WithError(err).Fatal("failed to serve")
21 | }
22 | },
23 | }
24 |
25 | var (
26 | cfgFile string
27 | log = logrus.New()
28 | )
29 |
30 | // Execute adds all child commands to the root command and sets flags appropriately.
31 | // This is called by main.main(). It only needs to happen once to the rootCmd.
32 | func Execute() {
33 | err := rootCmd.Execute()
34 | if err != nil {
35 | os.Exit(1)
36 | }
37 | }
38 |
39 | func init() {
40 | rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "config.yaml", "config file (default is config.yaml)")
41 | }
42 |
43 | func initCommon() *forky.Config {
44 | log.SetFormatter(&logrus.TextFormatter{})
45 |
46 | log.WithField("file", cfgFile).Info("Loading config")
47 |
48 | config, err := forky.NewConfigFromYAMLFile(cfgFile)
49 | if err != nil {
50 | log.Fatal(err)
51 | }
52 |
53 | logLevel, err := logrus.ParseLevel(config.LogLevel)
54 | if err != nil {
55 | log.WithField("log_level", config.LogLevel).Fatal("invalid log level")
56 | }
57 |
58 | log.SetLevel(logLevel)
59 |
60 | return config
61 | }
62 |
--------------------------------------------------------------------------------
/web/src/utils/testing.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react';
2 |
3 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4 |
5 | import { spec, networkName } from '@app/mocks/handlers';
6 | import ApplicationProvider from '@providers/application';
7 | import { Props as EthereumProps } from '@providers/ethereum';
8 | import { Props as FocusProps } from '@providers/focus';
9 | import { Props as SelectionProps } from '@providers/selection';
10 |
11 | const queryClient = new QueryClient();
12 |
13 | interface Props {
14 | ethereum?: Omit;
15 | focus?: Omit;
16 | selection?: Omit;
17 | }
18 |
19 | export function ProviderWrapper({ ethereum, focus, selection }: Props | undefined = {}) {
20 | const ethereumProps = ethereum || {
21 | genesisTime: new Date(spec.genesis_time).getTime(),
22 | secondsPerSlot: spec.seconds_per_slot,
23 | slotsPerEpoch: spec.slots_per_epoch,
24 | networkName: networkName,
25 | };
26 |
27 | const focusProps = focus || {
28 | playing: true,
29 | byo: false,
30 | initialTime:
31 | ethereumProps.genesisTime +
32 | 100 * (ethereumProps.secondsPerSlot * 1000) -
33 | ethereumProps.secondsPerSlot * 1000,
34 | };
35 |
36 | return function Provider({ children }: { children: ReactNode }) {
37 | return (
38 |
39 |
40 | {children}
41 |
42 |
43 | );
44 | };
45 | }
46 |
--------------------------------------------------------------------------------
/pkg/forky/db/metrics.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import "github.com/prometheus/client_golang/prometheus"
4 |
5 | type BasicMetrics struct {
6 | namespace string
7 |
8 | info *prometheus.GaugeVec
9 | operations *prometheus.CounterVec
10 | operationsErrors *prometheus.CounterVec
11 | }
12 |
13 | func NewBasicMetrics(namespace, driverName string, enabled bool) *BasicMetrics {
14 | m := &BasicMetrics{
15 | namespace: namespace,
16 | info: prometheus.NewGaugeVec(prometheus.GaugeOpts{
17 | Namespace: namespace,
18 | Name: "info",
19 | Help: "Information about the implementation of the db",
20 | }, []string{"driver"}),
21 |
22 | operations: prometheus.NewCounterVec(prometheus.CounterOpts{
23 | Namespace: namespace,
24 | Name: "operations_count",
25 | Help: "The count of operations performed by the db",
26 | }, []string{"operation"}),
27 | operationsErrors: prometheus.NewCounterVec(prometheus.CounterOpts{
28 | Namespace: namespace,
29 | Name: "operations_errors_count",
30 | Help: "The count of operations performed by the db that resulted in an error",
31 | }, []string{"operation"}),
32 | }
33 |
34 | if enabled {
35 | prometheus.MustRegister(m.info)
36 | prometheus.MustRegister(m.operations)
37 | prometheus.MustRegister(m.operationsErrors)
38 | }
39 |
40 | m.info.WithLabelValues(driverName).Set(1)
41 |
42 | return m
43 | }
44 |
45 | func (m *BasicMetrics) ObserveOperation(operation Operation) {
46 | m.operations.WithLabelValues(string(operation)).Inc()
47 | }
48 |
49 | func (m *BasicMetrics) ObserveOperationError(operation Operation) {
50 | m.operationsErrors.WithLabelValues(string(operation)).Inc()
51 | }
52 |
--------------------------------------------------------------------------------
/web/src/index.tsx:
--------------------------------------------------------------------------------
1 | import './styles/global.css';
2 | import React from 'react';
3 |
4 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
5 | import TimeAgo from 'javascript-time-ago';
6 | import en from 'javascript-time-ago/locale/en.json';
7 | import ReactDOM from 'react-dom/client';
8 | import { Route, Switch } from 'wouter';
9 |
10 | import App from '@app/App';
11 | import ErrorBoundary from '@app/ErrorBoundary';
12 |
13 | const queryClient = new QueryClient();
14 | TimeAgo.addDefaultLocale(en);
15 |
16 | // @ts-expect-error ignore
17 | if (process.env.NODE_ENV === 'development' && import.meta.env.VITE_MOCK) {
18 | const { worker } = await import('@app/mocks/browser');
19 | worker.start({
20 | waitUntilReady: true,
21 | });
22 | }
23 |
24 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
25 |
26 |
27 |
28 |
29 | {() => }
30 |
31 | {params => {
32 | const pathParts = params['*'].split('/');
33 | const hasEvents = pathParts[pathParts.length - 1] === 'events';
34 | const nodePath = hasEvents ? pathParts.slice(0, -1).join('/') : params['*'];
35 | return ;
36 | }}
37 |
38 | {({ id }) => }
39 |
40 |
41 |
42 |
43 |
44 |
45 | ,
46 | );
47 |
--------------------------------------------------------------------------------
/web/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js';
2 | import globals from 'globals';
3 | import reactHooks from 'eslint-plugin-react-hooks';
4 | import reactRefresh from 'eslint-plugin-react-refresh';
5 | import tseslint from 'typescript-eslint';
6 | import prettier from 'eslint-plugin-prettier/recommended';
7 | import vitest from '@vitest/eslint-plugin';
8 |
9 | export default tseslint.config(
10 | {
11 | ignores: [
12 | 'build',
13 | 'public',
14 | 'tailwind.config.js',
15 | 'craco.config.js',
16 | 'node_modules',
17 | 'coverage',
18 | 'eslint_report.json'
19 | ],
20 | },
21 | {
22 | extends: [js.configs.recommended, ...tseslint.configs.recommended, vitest.configs.recommended, prettier],
23 | files: ['**/*.{ts,tsx}'],
24 | languageOptions: {
25 | ecmaVersion: 'latest',
26 | globals: {
27 | ...globals.browser,
28 | ...globals.es2025,
29 | ...globals.jest,
30 | },
31 | },
32 | plugins: {
33 | 'react-hooks': reactHooks,
34 | 'react-refresh': reactRefresh,
35 | },
36 | rules: {
37 | ...reactHooks.configs.recommended.rules,
38 | 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
39 | 'react/prop-types': 'off',
40 | 'prettier/prettier': [
41 | 'error',
42 | {
43 | singleQuote: true,
44 | trailingComma: 'all',
45 | printWidth: 100,
46 | proseWrap: 'never',
47 | },
48 | ],
49 | radix: ['error', 'as-needed'],
50 | 'no-unused-vars': 'off',
51 | '@typescript-eslint/no-unused-vars': 'off',
52 | 'react/react-in-jsx-scope': 'off',
53 | },
54 | },
55 | );
56 |
--------------------------------------------------------------------------------
/web/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "target": "ES2020",
5 | "useDefineForClassFields": true,
6 | "lib": [
7 | "DOM",
8 | "DOM.Iterable",
9 | "ESNext"
10 | ],
11 | "allowJs": false,
12 | "skipLibCheck": true,
13 | "esModuleInterop": false,
14 | "allowSyntheticDefaultImports": true,
15 | "strict": true,
16 | "forceConsistentCasingInFileNames": true,
17 | "module": "ESNext",
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true,
21 | "moduleResolution": "bundler",
22 | "allowImportingTsExtensions": true,
23 | "moduleDetection": "force",
24 | "resolveJsonModule": true,
25 | "isolatedModules": true,
26 | "noEmit": true,
27 | "jsx": "react-jsx",
28 | "paths": {
29 | "@app/*": [
30 | "src/*"
31 | ],
32 | "@api/*": [
33 | "src/api/*"
34 | ],
35 | "@styles/*": [
36 | "src/styles/*"
37 | ],
38 | "@public/*": [
39 | "public/*"
40 | ],
41 | "@parts/*": [
42 | "src/parts/*"
43 | ],
44 | "@components/*": [
45 | "src/components/*"
46 | ],
47 | "@hooks/*": [
48 | "src/hooks/*"
49 | ],
50 | "@assets/*": [
51 | "src/assets/*"
52 | ],
53 | "@utils/*": [
54 | "src/utils/*"
55 | ],
56 | "@providers/*": [
57 | "src/providers/*"
58 | ],
59 | "@contexts/*": [
60 | "src/contexts/*"
61 | ]
62 | }
63 | },
64 | "include": [
65 | "src",
66 | "vitest.setup.ts"
67 | ],
68 | "exclude": [
69 | "src/**/__tests__/*"
70 | ]
71 | }
72 |
--------------------------------------------------------------------------------
/pkg/forky/api/http/metrics.go:
--------------------------------------------------------------------------------
1 | package http
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/prometheus/client_golang/prometheus"
7 | )
8 |
9 | type Metrics struct {
10 | requests *prometheus.CounterVec
11 | responses *prometheus.CounterVec
12 | requestDuration *prometheus.HistogramVec
13 | }
14 |
15 | func NewMetrics(enabled bool, namespace string) Metrics {
16 | m := Metrics{
17 | requests: prometheus.NewCounterVec(prometheus.CounterOpts{
18 | Namespace: namespace,
19 | Name: "request_count",
20 | Help: "Number of requests",
21 | }, []string{"method", "path"}),
22 | responses: prometheus.NewCounterVec(prometheus.CounterOpts{
23 | Namespace: namespace,
24 | Name: "response_count",
25 | Help: "Number of responses",
26 | }, []string{"method", "path", "code", "encoding"}),
27 | requestDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
28 | Namespace: namespace,
29 | Name: "request_duration_seconds",
30 | Help: "Request duration (in seconds.)",
31 | Buckets: []float64{0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
32 | }, []string{"method", "path", "encoding"}),
33 | }
34 |
35 | if enabled {
36 | prometheus.MustRegister(m.requests)
37 | prometheus.MustRegister(m.responses)
38 | prometheus.MustRegister(m.requestDuration)
39 | }
40 |
41 | return m
42 | }
43 |
44 | func (m Metrics) ObserveRequest(method, path string) {
45 | m.requests.WithLabelValues(method, path).Inc()
46 | }
47 |
48 | func (m Metrics) ObserveResponse(method, path, code, encoding string, duration time.Duration) {
49 | m.responses.WithLabelValues(method, path, code, encoding).Inc()
50 | m.requestDuration.WithLabelValues(method, path, encoding).Observe(duration.Seconds())
51 | }
52 |
--------------------------------------------------------------------------------
/pkg/forky/source/source.go:
--------------------------------------------------------------------------------
1 | package source
2 |
3 | import (
4 | "context"
5 | "fmt"
6 |
7 | "github.com/ethpandaops/forky/pkg/forky/types"
8 | "github.com/ethpandaops/forky/pkg/yaml"
9 | "github.com/sirupsen/logrus"
10 | )
11 |
12 | type Source interface {
13 | // Name returns the user-defined name of the source.
14 | Name() string
15 | // Type returns the type of the source.
16 | Type() string
17 | // Start starts the source.
18 | Start(ctx context.Context) error
19 | // Stop stops the source.
20 | Stop(ctx context.Context) error
21 |
22 | // OnFrame is called when a new frame has been received.
23 | OnFrame(func(ctx context.Context, frame *types.Frame))
24 | }
25 |
26 | var _ = Source(&BeaconNode{})
27 | var _ = Source(&XatuHTTP{})
28 |
29 | func NewSource(namespace string, log logrus.FieldLogger, name, sourceType string, config yaml.RawMessage, opts *Options) (Source, error) {
30 | namespace += "_source"
31 |
32 | metrics := NewBasicMetrics(namespace, sourceType, name, opts.MetricsEnabled)
33 |
34 | switch sourceType {
35 | case BeaconNodeType:
36 | conf := BeaconNodeConfig{}
37 |
38 | if err := config.Unmarshal(&conf); err != nil {
39 | return nil, err
40 | }
41 |
42 | source, err := NewBeaconNode(namespace, log, &conf, name, metrics)
43 | if err != nil {
44 | return nil, err
45 | }
46 |
47 | return source, nil
48 |
49 | case XatuHTTPType:
50 | conf := XatuHTTPConfig{}
51 |
52 | if err := config.Unmarshal(&conf); err != nil {
53 | return nil, err
54 | }
55 |
56 | source, err := NewXatuHTTP(namespace, name, log, &conf, metrics, opts)
57 | if err != nil {
58 | return nil, err
59 | }
60 |
61 | return source, nil
62 | default:
63 | return nil, fmt.Errorf("unknown source type: %s", sourceType)
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/pkg/forky/store/memory.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | import (
4 | "context"
5 | "sync"
6 |
7 | "github.com/ethpandaops/forky/pkg/forky/types"
8 | "github.com/sirupsen/logrus"
9 | )
10 |
11 | type MemoryStore struct {
12 | frames map[string]*types.Frame
13 | mu sync.Mutex
14 |
15 | opts *Options
16 |
17 | log logrus.FieldLogger
18 |
19 | basicMetrics *BasicMetrics
20 | }
21 |
22 | func NewMemoryStore(namespace string, log logrus.FieldLogger, opts *Options) *MemoryStore {
23 | metrics := NewBasicMetrics(namespace, string(MemoryStoreType), opts.MetricsEnabled)
24 |
25 | return &MemoryStore{
26 | frames: make(map[string]*types.Frame),
27 | log: log,
28 | opts: opts,
29 | basicMetrics: metrics,
30 | }
31 | }
32 |
33 | func (s *MemoryStore) SaveFrame(ctx context.Context, frame *types.Frame) error {
34 | s.mu.Lock()
35 | defer s.mu.Unlock()
36 |
37 | _, ok := s.frames[frame.Metadata.ID]
38 | if ok {
39 | return ErrFrameAlreadyStored
40 | }
41 |
42 | s.frames[frame.Metadata.ID] = frame
43 |
44 | s.basicMetrics.ObserveItemAdded(string(FrameDataType))
45 |
46 | return nil
47 | }
48 |
49 | func (s *MemoryStore) GetFrame(ctx context.Context, id string) (*types.Frame, error) {
50 | s.mu.Lock()
51 | defer s.mu.Unlock()
52 |
53 | frame, ok := s.frames[id]
54 | if !ok {
55 | return nil, ErrFrameNotFound
56 | }
57 |
58 | s.basicMetrics.ObserveItemRetreived(string(FrameDataType))
59 |
60 | return frame, nil
61 | }
62 |
63 | func (s *MemoryStore) DeleteFrame(ctx context.Context, id string) error {
64 | s.mu.Lock()
65 | defer s.mu.Unlock()
66 |
67 | _, ok := s.frames[id]
68 | if !ok {
69 | return ErrFrameNotFound
70 | }
71 |
72 | delete(s.frames, id)
73 |
74 | s.basicMetrics.ObserveItemRemoved(string(FrameDataType))
75 |
76 | return nil
77 | }
78 |
--------------------------------------------------------------------------------
/web/src/hooks/__tests__/useOutsideInteraction.test.ts:
--------------------------------------------------------------------------------
1 | import { renderHook, act } from '@testing-library/react';
2 | import { vi } from 'vitest';
3 |
4 | import useOutsideInteraction from '@hooks/useOutsideInteraction';
5 |
6 | describe('useOutsideInteraction', () => {
7 | it('should call the provided callback on outside mouse and touch events', () => {
8 | const callback = vi.fn();
9 | const { unmount } = renderHook(() => {
10 | const ref = { current: document.createElement('div') };
11 | useOutsideInteraction(ref, callback);
12 | return ref;
13 | });
14 |
15 | act(() => {
16 | const mouseDownEvent = new MouseEvent('mousedown');
17 | document.dispatchEvent(mouseDownEvent);
18 | });
19 |
20 | expect(callback).toHaveBeenCalledTimes(1);
21 |
22 | act(() => {
23 | const touchStartEvent = new TouchEvent('touchstart');
24 | document.dispatchEvent(touchStartEvent);
25 | });
26 |
27 | expect(callback).toHaveBeenCalledTimes(2);
28 |
29 | unmount();
30 | });
31 |
32 | it('should not call the provided callback on inside mouse and touch events', () => {
33 | const callback = vi.fn();
34 | const { result } = renderHook(() => {
35 | const ref = { current: document.createElement('div') };
36 | useOutsideInteraction(ref, callback);
37 | return ref;
38 | });
39 |
40 | act(() => {
41 | const mouseDownEvent = new MouseEvent('mousedown');
42 | result.current.current.dispatchEvent(mouseDownEvent);
43 | });
44 |
45 | expect(callback).toHaveBeenCalledTimes(0);
46 |
47 | act(() => {
48 | const touchStartEvent = new TouchEvent('touchstart');
49 | result.current.current.dispatchEvent(touchStartEvent);
50 | });
51 |
52 | expect(callback).toHaveBeenCalledTimes(0);
53 | });
54 | });
55 |
--------------------------------------------------------------------------------
/pkg/forky/api/http/content_type.go:
--------------------------------------------------------------------------------
1 | package http
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 | )
7 |
8 | type ContentType int
9 |
10 | const (
11 | ContentTypeUnknown ContentType = iota
12 | ContentTypeJSON
13 | ContentTypeYAML
14 | ContentTypeSSZ
15 | )
16 |
17 | func (c ContentType) String() string {
18 | switch c {
19 | case ContentTypeJSON:
20 | return "application/json"
21 | case ContentTypeYAML:
22 | return "application/yaml"
23 | case ContentTypeSSZ:
24 | return "application/octet-stream"
25 | case ContentTypeUnknown:
26 | return "unknown"
27 | }
28 |
29 | return ""
30 | }
31 |
32 | func DeriveContentType(accept string) ContentType {
33 | switch accept {
34 | case "application/json":
35 | return ContentTypeJSON
36 | case "*/*":
37 | return ContentTypeJSON
38 | case "application/yaml":
39 | return ContentTypeYAML
40 | case "application/octet-stream":
41 | return ContentTypeSSZ
42 | // Default to JSON if they don't care what they get.
43 | case "":
44 | return ContentTypeJSON
45 | }
46 |
47 | return ContentTypeUnknown
48 | }
49 |
50 | func ValidateContentType(contentType ContentType, accepting []ContentType) error {
51 | if !DoesAccept(accepting, contentType) {
52 | return fmt.Errorf("unsupported content-type: %s", contentType.String())
53 | }
54 |
55 | return nil
56 | }
57 |
58 | func DoesAccept(accepts []ContentType, input ContentType) bool {
59 | for _, a := range accepts {
60 | if a == input {
61 | return true
62 | }
63 | }
64 |
65 | return false
66 | }
67 |
68 | func NewContentTypeFromRequest(r *http.Request) ContentType {
69 | accept := r.Header.Get("Accept")
70 | if accept == "" {
71 | return ContentTypeJSON
72 | }
73 |
74 | content := DeriveContentType(accept)
75 |
76 | if content == ContentTypeUnknown {
77 | return ContentTypeJSON
78 | }
79 |
80 | return content
81 | }
82 |
--------------------------------------------------------------------------------
/web/src/parts/FrameFooter.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 |
3 | import { RectangleGroupIcon, RectangleStackIcon } from '@heroicons/react/24/solid';
4 | import { Link } from 'wouter';
5 |
6 | import Download from '@components/Download';
7 | import useAction from '@hooks/useActive';
8 | import { useFrameQuery } from '@hooks/useQuery';
9 |
10 | function FrameFooter() {
11 | const { ids } = useAction();
12 | const { data } = useFrameQuery(ids[0], ids.length > 0);
13 |
14 | return (
15 |
19 |
20 |
21 |
22 |
23 |
24 | Aggregated View
25 |
26 | {data && (
27 | <>
28 |
29 |
30 |
31 |
32 | Source View
33 |
34 |
35 |
40 | Download
41 |
42 | >
43 | )}
44 |
45 |
46 | );
47 | }
48 |
49 | export default memo(FrameFooter);
50 |
--------------------------------------------------------------------------------
/pkg/forky/api/ethereum.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "net/http"
7 |
8 | fhttp "github.com/ethpandaops/forky/pkg/forky/api/http"
9 |
10 | "github.com/julienschmidt/httprouter"
11 | )
12 |
13 | func (h *HTTP) handleV1GetEthereumSpec(ctx context.Context, _ *http.Request, _ httprouter.Params, contentType fhttp.ContentType) (*fhttp.Response, error) {
14 | if err := fhttp.ValidateContentType(contentType, []fhttp.ContentType{fhttp.ContentTypeJSON}); err != nil {
15 | return fhttp.NewUnsupportedMediaTypeResponse(nil), err
16 | }
17 |
18 | rsp := fhttp.V1GetEthereumSpecResponse{
19 | NetworkName: h.svc.GetEthereumNetworkName(ctx),
20 | Spec: fhttp.EthereumSpec{
21 | SecondsPerSlot: h.svc.GetEthereumSpecSecondsPerSlot(ctx),
22 | SlotsPerEpoch: h.svc.GetEthereumSpecSlotsPerEpoch(ctx),
23 | GenesisTime: h.svc.GetEthereumSpecGenesisTime(ctx),
24 | },
25 | }
26 |
27 | response := fhttp.NewSuccessResponse(fhttp.ContentTypeResolvers{
28 | fhttp.ContentTypeJSON: func() ([]byte, error) {
29 | return json.Marshal(rsp)
30 | },
31 | })
32 |
33 | response.SetCacheControl("public, max-age=60, s-maxage=60")
34 |
35 | return response, nil
36 | }
37 |
38 | func (h *HTTP) handleV1GetEthereumNow(ctx context.Context, _ *http.Request, _ httprouter.Params, contentType fhttp.ContentType) (*fhttp.Response, error) {
39 | if err := fhttp.ValidateContentType(contentType, []fhttp.ContentType{fhttp.ContentTypeJSON}); err != nil {
40 | return fhttp.NewUnsupportedMediaTypeResponse(nil), err
41 | }
42 |
43 | slot, epoch, err := h.svc.GetEthereumNow(ctx)
44 | if err != nil {
45 | return fhttp.NewInternalServerErrorResponse(nil), err
46 | }
47 |
48 | rsp := fhttp.V1GetEthereumNowResponse{
49 | Slot: uint64(slot),
50 | Epoch: uint64(epoch),
51 | }
52 |
53 | response := fhttp.NewSuccessResponse(fhttp.ContentTypeResolvers{
54 | fhttp.ContentTypeJSON: func() ([]byte, error) {
55 | return json.Marshal(rsp)
56 | },
57 | })
58 |
59 | response.SetCacheControl("public, max-age=1, s-maxage=1")
60 |
61 | return response, nil
62 | }
63 |
--------------------------------------------------------------------------------
/web/src/hooks/usePointer.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | export interface PointerState {
4 | x: number | null;
5 | up: boolean;
6 | }
7 |
8 | export interface PointerProps {
9 | listen: boolean;
10 | }
11 |
12 | const defaultPointerState: PointerState = {
13 | x: null,
14 | up: false,
15 | };
16 | export default function usePointer({ listen }: PointerProps): PointerState {
17 | const [pointerState, setPointerState] = useState(defaultPointerState);
18 |
19 | useEffect(() => {
20 | function handlePointerEvent(event: MouseEvent | TouchEvent) {
21 | if (event instanceof MouseEvent) {
22 | if (pointerState.x === event.clientX && pointerState.up === (event.type === 'mouseup')) {
23 | return;
24 | }
25 | setPointerState({
26 | x: event.clientX,
27 | up: event.type === 'mouseup',
28 | });
29 | } else if (event instanceof TouchEvent) {
30 | const touch = event.touches[0] as Touch | undefined;
31 | if (pointerState.x === touch?.clientX && pointerState.up === (event.type === 'touchend')) {
32 | return;
33 | }
34 | setPointerState({
35 | x: touch?.clientX ?? pointerState.x,
36 | up: event.type === 'touchend',
37 | });
38 | }
39 | }
40 |
41 | if (listen) {
42 | document.addEventListener('mousemove', handlePointerEvent);
43 | document.addEventListener('mouseup', handlePointerEvent);
44 | document.addEventListener('touchmove', handlePointerEvent, {
45 | passive: false,
46 | });
47 | document.addEventListener('touchend', handlePointerEvent, {
48 | passive: false,
49 | });
50 | }
51 |
52 | return () => {
53 | document.removeEventListener('mousemove', handlePointerEvent);
54 | document.removeEventListener('mouseup', handlePointerEvent);
55 | document.removeEventListener('touchmove', handlePointerEvent);
56 | document.removeEventListener('touchend', handlePointerEvent);
57 | };
58 | }, [listen, pointerState.x, pointerState.up]);
59 |
60 | return pointerState;
61 | }
62 |
--------------------------------------------------------------------------------
/web/src/components/SlotBoundary.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 |
3 | import { Transition } from '@headlessui/react';
4 | import classNames from 'clsx';
5 |
6 | function SlotBoundary({
7 | slot,
8 | epoch,
9 | x,
10 | y,
11 | width,
12 | height,
13 | className,
14 | textOffset,
15 | }: {
16 | slot?: number;
17 | epoch?: number;
18 | x: number;
19 | y: number;
20 | width: number;
21 | height: number;
22 | className?: string;
23 | textOffset: number;
24 | }) {
25 | return (
26 |
36 |
37 |
46 | {epoch && (
47 |
54 | EPOCH {epoch}
55 |
56 | )}
57 | {slot && (
58 |
65 | SLOT {slot}
66 |
67 | )}
68 |
69 |
70 | );
71 | }
72 |
73 | export default memo(SlotBoundary);
74 |
--------------------------------------------------------------------------------
/pkg/forky/api/http.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | // Added
5 | "context" // Added
6 | // Added
7 | "net/http"
8 | // Added
9 | // Added
10 | fhttp "github.com/ethpandaops/forky/pkg/forky/api/http"
11 | "github.com/pkg/errors"
12 |
13 | "github.com/ethpandaops/forky/pkg/forky/service"
14 | "github.com/julienschmidt/httprouter"
15 | "github.com/sirupsen/logrus"
16 | )
17 |
18 | type HTTP struct {
19 | log logrus.FieldLogger
20 | svc *service.ForkChoice
21 | metrics *fhttp.Metrics
22 | config *Config
23 | opts *Options
24 | }
25 |
26 | func NewHTTP(log logrus.FieldLogger, svc *service.ForkChoice, config *Config, opts *Options) (*HTTP, error) {
27 | if err := config.Validate(); err != nil {
28 | return nil, errors.Wrap(err, "invalid http config")
29 | }
30 |
31 | metrics := fhttp.NewMetrics(opts.MetricsEnabled, "http")
32 |
33 | return &HTTP{
34 | opts: opts,
35 | config: config,
36 | svc: svc,
37 | log: log.WithField("component", "http"),
38 | metrics: &metrics,
39 | }, nil
40 | }
41 |
42 | func (h *HTTP) BindToRouter(_ context.Context, router *httprouter.Router) error {
43 | router.GET("/api/v1/ethereum/now", h.wrappedHandler(h.handleV1GetEthereumNow))
44 | router.GET("/api/v1/ethereum/spec", h.wrappedHandler(h.handleV1GetEthereumSpec))
45 |
46 | router.GET("/api/v1/frames/:id", h.wrappedHandler(h.handleV1GetFrame))
47 |
48 | router.POST("/api/v1/metadata", h.wrappedHandler(h.handleV1MetadataList))
49 | router.POST("/api/v1/metadata/nodes", h.wrappedHandler(h.handleV1MetadataListNodes))
50 | router.POST("/api/v1/metadata/slots", h.wrappedHandler(h.handleV1MetadataListSlots))
51 | router.POST("/api/v1/metadata/epochs", h.wrappedHandler(h.handleV1MetadataListEpochs))
52 | router.POST("/api/v1/metadata/labels", h.wrappedHandler(h.handleV1MetadataListLabels))
53 |
54 | return nil
55 | }
56 |
57 | func (h *HTTP) wrappedHandler(handler func(ctx context.Context, r *http.Request, p httprouter.Params, contentType fhttp.ContentType) (*fhttp.Response, error)) httprouter.Handle {
58 | return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
59 | fhttp.WrappedHandler(h.log, h.metrics, handler)(w, r, p)
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/web/src/hooks/useQuery.ts:
--------------------------------------------------------------------------------
1 | import { useQuery, useQueries } from '@tanstack/react-query';
2 |
3 | import { fetchNow, fetchSpec, Spec } from '@api/ethereum';
4 | import { fetchFrame } from '@api/frames';
5 | import { fetchMetadataList, fetchMetadataNodes } from '@api/metadata';
6 | import { FrameFilter, FrameMetaData, V1GetEthereumNowResponse } from '@app/types/api';
7 | import { ProcessedData } from '@app/types/graph';
8 |
9 | export function useNowQuery(enabled = true) {
10 | return useQuery<
11 | Required,
12 | unknown,
13 | Required,
14 | string[]
15 | >({
16 | queryKey: ['now'],
17 | queryFn: () => fetchNow(),
18 | enabled,
19 | staleTime: 60_000,
20 | });
21 | }
22 |
23 | export function useSpecQuery(enabled = true) {
24 | return useQuery({
25 | queryKey: ['spec'],
26 | queryFn: () => fetchSpec(),
27 | enabled,
28 | staleTime: 60_000,
29 | });
30 | }
31 |
32 | export function useNodesQuery(filter: FrameFilter, enabled = true) {
33 | return useQuery({
34 | queryKey: ['metadata', filter],
35 | queryFn: () => fetchMetadataNodes({ pagination: { limit: 100 }, filter }),
36 | enabled,
37 | staleTime: 6_000,
38 | });
39 | }
40 |
41 | export function useMetadataQuery(filter: FrameFilter, enabled = true) {
42 | return useQuery({
43 | queryKey: ['metadata', filter],
44 | queryFn: () => fetchMetadataList({ pagination: { limit: 1000 }, filter }),
45 | enabled,
46 | staleTime: 6_000,
47 | });
48 | }
49 |
50 | export function useFrameQuery(id: string, enabled = true) {
51 | return useQuery({
52 | queryKey: ['frame', id],
53 | queryFn: () => fetchFrame(id),
54 | enabled,
55 | staleTime: 120_000,
56 | });
57 | }
58 |
59 | export function useFrameQueries(ids: string[], enabled = true) {
60 | return useQueries({
61 | queries: ids.map(id => ({
62 | queryKey: ['frame', id],
63 | queryFn: () => fetchFrame(id),
64 | enabled,
65 | staleTime: 120_000,
66 | })),
67 | });
68 | }
69 |
--------------------------------------------------------------------------------
/pkg/forky/ethereum/ethereum.go:
--------------------------------------------------------------------------------
1 | package ethereum
2 |
3 | import (
4 | "fmt"
5 | "time"
6 |
7 | "github.com/ethpandaops/ethwallclock"
8 | "github.com/pkg/errors"
9 | "github.com/sirupsen/logrus"
10 | )
11 |
12 | type BeaconChain struct {
13 | log logrus.FieldLogger
14 | wallclock *ethwallclock.EthereumBeaconChain
15 | config *Config
16 |
17 | genesisTime time.Time
18 | }
19 |
20 | func NewBeaconChain(log logrus.FieldLogger, config *Config) (*BeaconChain, error) {
21 | if err := config.Validate(); err != nil {
22 | return nil, err
23 | }
24 |
25 | secondsPerSlot, err := time.ParseDuration(fmt.Sprintf("%vs", config.Network.Spec.SecondsPerSlot))
26 | if err != nil {
27 | return nil, errors.Wrap(err, "failed to parse seconds per slot")
28 | }
29 |
30 | //nolint:gosec // ignore integer overflow conversion uint64 -> int64
31 | genesisTime := time.Unix(int64(config.Network.Spec.GenesisTime), 0)
32 |
33 | wc := ethwallclock.NewEthereumBeaconChain(genesisTime, secondsPerSlot, config.Network.Spec.SlotsPerEpoch)
34 |
35 | return &BeaconChain{
36 | log: log.WithField("component", "ethereum/beaconchain"),
37 | wallclock: wc,
38 | config: config,
39 | genesisTime: genesisTime,
40 | }, nil
41 | }
42 |
43 | func (b *BeaconChain) Start() error {
44 | b.log.WithFields(logrus.Fields{
45 | "network": b.config.Network.Name,
46 | }).Info("starting ethereum beacon chain")
47 |
48 | return nil
49 | }
50 |
51 | func (b *BeaconChain) Wallclock() *ethwallclock.EthereumBeaconChain {
52 | return b.wallclock
53 | }
54 |
55 | func (b *BeaconChain) Stop() error {
56 | b.log.WithFields(logrus.Fields{
57 | "network": b.config.Network.Name,
58 | }).Info("stopping ethereum beacon chain")
59 |
60 | return nil
61 | }
62 |
63 | func (b *BeaconChain) SlotsPerEpoch() uint64 {
64 | return b.config.Network.Spec.SlotsPerEpoch
65 | }
66 |
67 | func (b *BeaconChain) SecondsPerSlot() uint64 {
68 | return b.config.Network.Spec.SecondsPerSlot
69 | }
70 |
71 | func (b *BeaconChain) GenesisTime() time.Time {
72 | return b.genesisTime
73 | }
74 |
75 | func (b *BeaconChain) Spec() *SpecConfig {
76 | return &b.config.Network.Spec
77 | }
78 |
79 | func (b *BeaconChain) NetworkName() string {
80 | return b.config.Network.Name
81 | }
82 |
--------------------------------------------------------------------------------
/web/src/utils/__tests__/strings.test.ts:
--------------------------------------------------------------------------------
1 | import { truncateHash, convertToHexColor } from '@utils/strings';
2 |
3 | describe('strings', () => {
4 | describe('truncateHash', () => {
5 | it('should return an empty string if no hash is passed', () => {
6 | expect(truncateHash()).toBe('');
7 | });
8 |
9 | it('should return the first 6 characters and the last 4 characters of a hash', () => {
10 | expect(truncateHash('0x1234567890abcdef')).toBe('0x1234...cdef');
11 | });
12 |
13 | it('should return full hash if length of 10 or under', () => {
14 | expect(truncateHash('0x12cdef')).toBe('0x12cdef');
15 | expect(truncateHash('0x1234abcd')).toBe('0x1234abcd');
16 | });
17 | });
18 |
19 | describe('convertToHexColor', () => {
20 | it('should return different colors for different input strings', () => {
21 | const color1 = convertToHexColor('example1');
22 | const color2 = convertToHexColor('example3');
23 | const color3 = convertToHexColor('example5');
24 |
25 | expect(color1).not.toBe(color2);
26 | expect(color1).not.toBe(color3);
27 | expect(color2).not.toBe(color3);
28 | });
29 |
30 | it('should return the same color for the same input string', () => {
31 | const color1 = convertToHexColor('example1');
32 | const color2 = convertToHexColor('example1');
33 |
34 | expect(color1).toBe(color2);
35 | });
36 |
37 | it('should return a color in valid hex format', () => {
38 | const hexColorRegex = /^#[0-9a-fA-F]{6}$/;
39 |
40 | expect(convertToHexColor('example1')).toMatch(hexColorRegex);
41 | expect(convertToHexColor('example2')).toMatch(hexColorRegex);
42 | expect(convertToHexColor('example3')).toMatch(hexColorRegex);
43 | });
44 |
45 | it('should return the different color for strings with the same characters but different cases', () => {
46 | const color1 = convertToHexColor('Example1');
47 | const color2 = convertToHexColor('example1');
48 |
49 | expect(color1).not.toBe(color2);
50 | });
51 |
52 | it('should handle very long strings and return a valid web-safe color', () => {
53 | const hexColorRegex = /^#[0-9a-fA-F]{6}$/;
54 |
55 | expect(convertToHexColor('a'.repeat(10000))).toMatch(hexColorRegex);
56 | });
57 | });
58 | });
59 |
--------------------------------------------------------------------------------
/.github/workflows/alpha-releases.yaml:
--------------------------------------------------------------------------------
1 | name: alpha releases
2 |
3 | on:
4 | push:
5 | branches:
6 | - 'release/*'
7 |
8 | jobs:
9 | tag-release:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout
13 | uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
14 | with:
15 | fetch-depth: 0
16 | ref: ${{ github.ref }}
17 | token: ${{ secrets.EPOBOT_TOKEN }}
18 | - name: Get latest release version for this release branch
19 | if: startsWith(github.ref, 'refs/heads/release/')
20 | id: latest_version
21 | run: |
22 | # Extract suffix from branch name (e.g., 'dencun' from 'release/dencun')
23 | RELEASE_SUFFIX=${GITHUB_REF#refs/heads/release/}
24 |
25 | # Fetch all tags and get the latest that matches the pattern
26 | # Using the refs/tags API
27 | LATEST_VERSION=$(curl --silent "https://api.github.com/repos/$GITHUB_REPOSITORY/git/refs/tags" \
28 | | jq -r --arg suffix "$RELEASE_SUFFIX" '.[] | select(.ref | test("refs/tags/v?[0-9]+\\.[0-9]+\\.[0-9]+-" + $suffix + "$")) | .ref' \
29 | | sed 's|refs/tags/||' | sort -V | tail -n 1)
30 | echo "Found latest $RELEASE_SUFFIX version: $LATEST_VERSION"
31 |
32 | # Default to 0.0.0 if no matching release was found
33 | if [[ -z "$LATEST_VERSION" ]]; then
34 | LATEST_VERSION="0.0.0"
35 | fi
36 |
37 | # Increment the patch version using bash
38 | LATEST_VERSION=$(echo "$LATEST_VERSION" | awk -F. -v OFS=. '{$NF = $NF + 1;} 1')
39 |
40 | VERSION=$LATEST_VERSION-$RELEASE_SUFFIX
41 |
42 | echo "Releasing version: $VERSION"
43 |
44 | git config --global user.email "ethpandaopsbot@ethereum.org"
45 | git config --global user.name "ethpandaopsbot"
46 |
47 | # Log the short commit SHA
48 | SHORT_COMMIT=$(git rev-parse --short HEAD)
49 |
50 | echo "Git commit: $SHORT_COMMIT"
51 |
52 | git tag -a "$VERSION" -m "Release $VERSION"
53 |
54 | echo "RELEASE_SUFFIX=$RELEASE_SUFFIX" >> $GITHUB_ENV
55 |
56 | # Push the tag
57 | git push origin "$VERSION"
58 | env:
59 | GITHUB_TOKEN: ${{ secrets.EPOBOT_TOKEN }}
60 |
--------------------------------------------------------------------------------
/web/src/components/SlotDial.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import Slot from '@components/Slot';
4 | import TimeDrag from '@components/TimeDrag';
5 | import useEthereum from '@contexts/ethereum';
6 | import useFocus from '@contexts/focus';
7 | import useWindowSize from '@hooks/useWindowSize';
8 |
9 | const SUB_MARKS = 4;
10 | const PADDING = 2;
11 | const MARKER_WIDTH = 4;
12 |
13 | const SlotDial = () => {
14 | const { secondsPerSlot } = useEthereum();
15 | const { slot: focusedSlot, timeIntoSlot } = useFocus();
16 | const [width] = useWindowSize();
17 |
18 | const slotWidth = secondsPerSlot * MARKER_WIDTH * (SUB_MARKS + 1);
19 | const segments = slotWidth / MARKER_WIDTH;
20 | const slotPadding = Math.ceil(((width / slotWidth) * 1.25) / 2);
21 | const multiplier = 250 / (SUB_MARKS + 1);
22 | const middleSlotX = width / 2 - (slotWidth / (secondsPerSlot * 1000)) * timeIntoSlot;
23 |
24 | const leftSideRulers = Array.from(
25 | { length: focusedSlot < slotPadding ? focusedSlot : slotPadding },
26 | (_, i) => {
27 | return (
28 |
29 |
35 |
36 | );
37 | },
38 | );
39 |
40 | const rightSideRulers = Array.from({ length: slotPadding }, (_, i) => {
41 | return (
42 |
43 |
49 |
50 | );
51 | });
52 |
53 | return (
54 |
55 |
56 | {leftSideRulers}
57 |
58 |
59 |
60 | {rightSideRulers}
61 |
62 |
63 | );
64 | };
65 |
66 | export default React.memo(SlotDial);
67 |
--------------------------------------------------------------------------------
/web/src/utils/__tests__/maths.test.ts:
--------------------------------------------------------------------------------
1 | import { arcToRadiansByPercentage } from '@utils/maths';
2 |
3 | describe('maths', () => {
4 | describe('arcToRadiansByPercentage', () => {
5 | it('should return half circle radians', () => {
6 | const fromRadians = 0;
7 | const percentage = 50;
8 | const expected = Math.PI.toFixed(6);
9 | const actual = arcToRadiansByPercentage(fromRadians, percentage).toFixed(6);
10 | expect(expected).toBe(actual);
11 | });
12 |
13 | it('should return full circle radians', () => {
14 | const fromRadians = 0;
15 | const percentage = 100;
16 | const expected = (2 * Math.PI).toFixed(6);
17 | const actual = arcToRadiansByPercentage(fromRadians, percentage).toFixed(6);
18 | expect(expected).toBe(actual);
19 | });
20 |
21 | it('should return 0 radians', () => {
22 | const fromRadians = 0;
23 | const percentage = 0;
24 | const expected = 0;
25 | const actual = arcToRadiansByPercentage(fromRadians, percentage);
26 | expect(expected).toBe(actual);
27 | });
28 |
29 | it('should return 0 radians if percentage is negative', () => {
30 | const fromRadians = 0;
31 | const percentage = -1;
32 | const expected = 0;
33 | const actual = arcToRadiansByPercentage(fromRadians, percentage);
34 | expect(expected).toBe(actual);
35 | });
36 |
37 | it('should return 0 radians if percentage is NaN', () => {
38 | const fromRadians = 0;
39 | const percentage = NaN;
40 | const expected = 0;
41 | const actual = arcToRadiansByPercentage(fromRadians, percentage);
42 | expect(expected).toBe(actual);
43 | });
44 |
45 | it('should return half circle radians with alternate start angle', () => {
46 | const fromRadians = Math.PI;
47 | const percentage = 50;
48 | const expected = (2 * Math.PI).toFixed(6);
49 | const actual = arcToRadiansByPercentage(fromRadians, percentage).toFixed(6);
50 | expect(expected).toBe(actual);
51 | });
52 |
53 | it('should return full circle radians with alternate start angle', () => {
54 | const fromRadians = Math.PI;
55 | const percentage = 100;
56 | const expected = (3 * Math.PI).toFixed(6);
57 | const actual = arcToRadiansByPercentage(fromRadians, percentage).toFixed(6);
58 | expect(expected).toBe(actual);
59 | });
60 | });
61 | });
62 |
--------------------------------------------------------------------------------
/pkg/forky/ethereum/ethereum_test.go:
--------------------------------------------------------------------------------
1 | package ethereum
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/ethpandaops/ethwallclock"
7 | "github.com/sirupsen/logrus"
8 | "github.com/stretchr/testify/assert"
9 | )
10 |
11 | func TestNewBeaconChain_ValidConfig_ReturnsBeaconChain(t *testing.T) {
12 | config := &Config{
13 | Network: NetworkConfig{
14 | Name: "TestNet",
15 | Spec: SpecConfig{
16 | SlotsPerEpoch: 32,
17 | SecondsPerSlot: 12,
18 | GenesisTime: 1590832934,
19 | },
20 | },
21 | }
22 | log := logrus.New()
23 | beaconchain, err := NewBeaconChain(log, config)
24 | assert.NoError(t, err)
25 | assert.NotNil(t, beaconchain)
26 | }
27 |
28 | func TestNewBeaconChain_InvalidConfig_ReturnsError(t *testing.T) {
29 | config := &Config{}
30 | log := logrus.New()
31 | beaconchain, err := NewBeaconChain(log, config)
32 | assert.Error(t, err)
33 | assert.Nil(t, beaconchain)
34 | }
35 |
36 | func TestBeaconChain_Wallclock_ReturnsEthereumBeaconChain(t *testing.T) {
37 | config := &Config{
38 | Network: NetworkConfig{
39 | Name: "TestNet",
40 | Spec: SpecConfig{
41 | SlotsPerEpoch: 32,
42 | SecondsPerSlot: 12,
43 | GenesisTime: 1590832934,
44 | },
45 | },
46 | }
47 | log := logrus.New()
48 | beaconchain, _ := NewBeaconChain(log, config)
49 | wallclock := beaconchain.Wallclock()
50 | assert.NotNil(t, wallclock)
51 | assert.IsType(t, ðwallclock.EthereumBeaconChain{}, wallclock)
52 | }
53 |
54 | func TestBeaconChain_SlotsPerEpoch_ReturnsSlotsPerEpoch(t *testing.T) {
55 | config := &Config{
56 | Network: NetworkConfig{
57 | Name: "TestNet",
58 | Spec: SpecConfig{
59 | SlotsPerEpoch: 32,
60 | SecondsPerSlot: 12,
61 | GenesisTime: 1590832934,
62 | },
63 | },
64 | }
65 | log := logrus.New()
66 | beaconchain, _ := NewBeaconChain(log, config)
67 | slotsPerEpoch := beaconchain.SlotsPerEpoch()
68 | assert.Equal(t, uint64(32), slotsPerEpoch)
69 | }
70 |
71 | func TestBeaconChain_SecondsPerSlot_ReturnsSecondsPerSlot(t *testing.T) {
72 | config := &Config{
73 | Network: NetworkConfig{
74 | Name: "TestNet",
75 | Spec: SpecConfig{
76 | SlotsPerEpoch: 32,
77 | SecondsPerSlot: 12,
78 | GenesisTime: 1590832934,
79 | },
80 | },
81 | }
82 | log := logrus.New()
83 | beaconchain, _ := NewBeaconChain(log, config)
84 | secondsPerSlot := beaconchain.SecondsPerSlot()
85 | assert.Equal(t, uint64(12), secondsPerSlot)
86 | }
87 |
--------------------------------------------------------------------------------
/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "forkchoice",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "build": "tsc -b tsconfig.app.json && vite build",
8 | "dev:mock": "VITE_MOCK=true vite",
9 | "dev": "vite",
10 | "generate": "NODE_OPTIONS=--experimental-strip-types NODE_NO_WARNINGS=1 node dataset-generate.ts",
11 | "lint:report": "eslint --output-file eslint_report.json --format json src",
12 | "lint": "eslint .",
13 | "preview": "vite preview",
14 | "test:coverage": "vitest run --coverage",
15 | "test": "vitest"
16 | },
17 | "dependencies": {
18 | "@headlessui/react": "2.2.2",
19 | "@heroicons/react": "2.2.0",
20 | "@tailwindcss/vite": "4.1.5",
21 | "@tanstack/react-query": "5.75.0",
22 | "@tanstack/react-query-devtools": "5.75.0",
23 | "clsx": "2.1.1",
24 | "graphology": "0.26.0",
25 | "graphology-layout": "0.6.1",
26 | "graphology-traversal": "0.3.1",
27 | "graphology-types": "0.24.8",
28 | "graphology-utils": "2.5.2",
29 | "javascript-time-ago": "2.5.11",
30 | "react": "19.1.0",
31 | "react-dom": "19.1.0",
32 | "react-router-dom": "7.5.3",
33 | "react-time-ago": "7.3.3",
34 | "react-zoom-pan-pinch": "3.7.0",
35 | "wouter": "3.7.0"
36 | },
37 | "devDependencies": {
38 | "@eslint/js": "9.25.1",
39 | "@testing-library/dom": "10.4.0",
40 | "@testing-library/react": "16.3.0",
41 | "@types/js-yaml": "4.0.9",
42 | "@types/react": "19.1.2",
43 | "@types/react-dom": "19.1.3",
44 | "@vitejs/plugin-react": "4.4.1",
45 | "@vitest/coverage-v8": "3.1.2",
46 | "@vitest/eslint-plugin": "1.1.44",
47 | "autoprefixer": "10.4.21",
48 | "eslint": "9.25.1",
49 | "eslint-config-prettier": "10.1.2",
50 | "eslint-plugin-prettier": "5.2.6",
51 | "eslint-plugin-react-hooks": "5.2.0",
52 | "eslint-plugin-react-refresh": "0.4.20",
53 | "globals": "16.0.0",
54 | "jsdom": "26.1.0",
55 | "msw": "2.7.5",
56 | "prettier": "3.5.3",
57 | "prop-types": "15.8.1",
58 | "typescript": "5.8.3",
59 | "typescript-eslint": "8.31.1",
60 | "vite": "6.3.4",
61 | "vite-tsconfig-paths": "5.1.4",
62 | "vitest": "3.1.2",
63 | "vitest-github-actions-reporter": "0.11.1"
64 | },
65 | "volta": {
66 | "node": "23.11.0",
67 | "npm": "10.9.2"
68 | },
69 | "msw": {
70 | "workerDirectory": "public"
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/pkg/forky/db/filters.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "errors"
5 | "time"
6 |
7 | "gorm.io/gorm"
8 | )
9 |
10 | type FrameFilter struct {
11 | ID *string
12 | Node *string
13 | Before *time.Time
14 | After *time.Time
15 | Slot *uint64
16 | Epoch *uint64
17 | Labels *[]string
18 | ConsensusClient *string
19 | EventSource *int
20 | }
21 |
22 | func (f *FrameFilter) AddID(id string) {
23 | f.ID = &id
24 | }
25 |
26 | func (f *FrameFilter) AddNode(node string) {
27 | f.Node = &node
28 | }
29 |
30 | func (f *FrameFilter) AddBefore(before time.Time) {
31 | f.Before = &before
32 | }
33 |
34 | func (f *FrameFilter) AddAfter(after time.Time) {
35 | f.After = &after
36 | }
37 |
38 | func (f *FrameFilter) AddSlot(slot uint64) {
39 | f.Slot = &slot
40 | }
41 |
42 | func (f *FrameFilter) AddEpoch(epoch uint64) {
43 | f.Epoch = &epoch
44 | }
45 |
46 | func (f *FrameFilter) AddLabels(labels []string) {
47 | f.Labels = &labels
48 | }
49 |
50 | func (f *FrameFilter) AddConsensusClient(consensusClient string) {
51 | f.ConsensusClient = &consensusClient
52 | }
53 |
54 | func (f *FrameFilter) AddEventSource(eventSource int) {
55 | f.EventSource = &eventSource
56 | }
57 |
58 | func (f *FrameFilter) Validate() error {
59 | if f.ID == nil &&
60 | f.Node == nil &&
61 | f.Before == nil &&
62 | f.After == nil &&
63 | f.Slot == nil &&
64 | f.Epoch == nil &&
65 | f.Labels == nil &&
66 | f.ConsensusClient == nil &&
67 | f.EventSource == nil {
68 | return errors.New("no filter specified")
69 | }
70 |
71 | return nil
72 | }
73 |
74 | func (f *FrameFilter) ApplyToQuery(query *gorm.DB) (*gorm.DB, error) {
75 | if f.ID != nil {
76 | query = query.Where("id = ?", f.ID)
77 | }
78 |
79 | if f.Node != nil {
80 | query = query.Where("node = ?", f.Node)
81 | }
82 |
83 | if f.Before != nil {
84 | query = query.Where("fetched_at <= ?", f.Before)
85 | }
86 |
87 | if f.After != nil {
88 | query = query.Where("fetched_at >= ?", f.After)
89 | }
90 |
91 | if f.Slot != nil {
92 | query = query.Where("wall_clock_slot = ?", f.Slot)
93 | }
94 |
95 | if f.Epoch != nil {
96 | query = query.Where("wall_clock_epoch = ?", f.Epoch)
97 | }
98 |
99 | if f.EventSource != nil {
100 | query = query.Where("event_source = ?", f.EventSource)
101 | }
102 |
103 | if f.ConsensusClient != nil {
104 | query = query.Where("consensus_client = ?", f.ConsensusClient)
105 | }
106 |
107 | return query, nil
108 | }
109 |
--------------------------------------------------------------------------------
/web/src/hooks/__tests__/useQuery.test.ts:
--------------------------------------------------------------------------------
1 | import { renderHook, waitFor } from '@testing-library/react';
2 |
3 | import { getNow, networkName, spec } from '@app/mocks/handlers';
4 | import {
5 | useSpecQuery,
6 | useNowQuery,
7 | useFrameQuery,
8 | useMetadataQuery,
9 | useNodesQuery,
10 | } from '@hooks/useQuery';
11 | import { ProviderWrapper } from '@utils/testing';
12 |
13 | describe('useQuery', () => {
14 | describe('useNowQuery', () => {
15 | it('should return current slot and epoch', async () => {
16 | const { result } = renderHook(() => useNowQuery(), {
17 | wrapper: ProviderWrapper(),
18 | });
19 |
20 | const now = getNow();
21 |
22 | await waitFor(() => result.current.isSuccess);
23 | await waitFor(() => expect(result.current.data?.slot).toEqual(now.slot));
24 | await waitFor(() => expect(result.current.data?.epoch).toEqual(now.epoch));
25 | });
26 | });
27 |
28 | describe('useSpecQuery', () => {
29 | it('should return metadata', async () => {
30 | const { result } = renderHook(() => useSpecQuery(), {
31 | wrapper: ProviderWrapper(),
32 | });
33 |
34 | await waitFor(() => result.current.isSuccess);
35 | await waitFor(() => expect(result.current.data?.network_name).toEqual(networkName));
36 | await waitFor(() => expect(result.current.data?.spec).toEqual(spec));
37 | });
38 | });
39 |
40 | describe('useMetadataQuery', () => {
41 | it('should return metadata', async () => {
42 | const { result } = renderHook(() => useMetadataQuery({}), {
43 | wrapper: ProviderWrapper(),
44 | });
45 |
46 | await waitFor(() => result.current.isSuccess);
47 | await waitFor(() => expect(result.current.data?.length).toBeGreaterThan(0));
48 | });
49 | });
50 |
51 | describe('useNodeQuery', () => {
52 | it('should return nodes', async () => {
53 | const { result } = renderHook(() => useNodesQuery({}), {
54 | wrapper: ProviderWrapper(),
55 | });
56 |
57 | await waitFor(() => result.current.isSuccess);
58 | await waitFor(() => expect(result.current.data?.length).toBeGreaterThan(0));
59 | });
60 | });
61 |
62 | describe('useFrameQuery', () => {
63 | it('should return the frame metadata id', async () => {
64 | const { result } = renderHook(() => useFrameQuery('123'), {
65 | wrapper: ProviderWrapper(),
66 | });
67 |
68 | await waitFor(() => result.current.isSuccess);
69 | await waitFor(() => expect(result.current.data?.frame.metadata.id).toEqual('123'));
70 | });
71 | });
72 | });
73 |
--------------------------------------------------------------------------------
/web/src/components/Ruler.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import classNames from 'clsx';
4 |
5 | interface RulerProps {
6 | summary: string;
7 | marks: number;
8 | subMarks?: number;
9 | markText?: boolean;
10 | markSuffix?: string;
11 | flip?: boolean;
12 | className?: string;
13 | style?: React.CSSProperties;
14 | children?: React.ReactNode;
15 | }
16 |
17 | const Ruler: React.FC = ({
18 | summary,
19 | marks,
20 | subMarks,
21 | markText,
22 | markSuffix,
23 | flip = false,
24 | className,
25 | style,
26 | children,
27 | }) => {
28 | const subMarksInterval = (subMarks ?? 0) + 1;
29 | const totalSubmarks = marks * subMarksInterval - 1;
30 |
31 | const generateRulerMarks = () => {
32 | const marks = [];
33 | for (let i = 0; i <= totalSubmarks; i++) {
34 | const isCentimeter = i % subMarksInterval === 0;
35 |
36 | marks.push(
37 |
45 | {markText && isCentimeter && i !== 0 && i != totalSubmarks && (
46 |
52 | {i / subMarksInterval}
53 | {markSuffix ? markSuffix : ''}
54 |
55 | )}
56 |
,
57 | );
58 | }
59 | return marks;
60 | };
61 |
62 | return (
63 |
64 |
70 |
71 |
{children}
72 | {summary && (
73 |
74 | {summary}
75 |
76 | )}
77 |
78 |
81 | {generateRulerMarks()}
82 |
83 |
84 | );
85 | };
86 |
87 | export default Ruler;
88 |
--------------------------------------------------------------------------------
/web/src/components/EpochDial.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from 'react';
2 |
3 | import Ruler from '@components/Ruler';
4 | import TimeDrag from '@components/TimeDrag';
5 | import useEthereum from '@contexts/ethereum';
6 | import useFocus from '@contexts/focus';
7 | import useWindowSize from '@hooks/useWindowSize';
8 |
9 | const SUB_MARKS = 0;
10 |
11 | function SlotDial() {
12 | const { slotsPerEpoch, secondsPerSlot } = useEthereum();
13 | const { epoch: focusedEpoch, timeIntoEpoch: focusedTimeIntoEpoch } = useFocus();
14 | const [width] = useWindowSize();
15 |
16 | const epochWidth = slotsPerEpoch * 4 * (SUB_MARKS + 1);
17 | const epochPadding = Math.ceil(((width / epochWidth) * 1.25) / 2);
18 | const multiplier = (250 / (SUB_MARKS + 1)) * secondsPerSlot;
19 | const middleSlotX =
20 | width / 2 - (epochWidth / (slotsPerEpoch * secondsPerSlot * 1000)) * focusedTimeIntoEpoch;
21 |
22 | const leftSideRulers = Array.from(
23 | { length: focusedEpoch < epochPadding ? focusedEpoch : epochPadding },
24 | (_, i) => {
25 | return (
26 |
27 |
34 |
35 | );
36 | },
37 | );
38 |
39 | const rightSideRulers = Array.from({ length: epochPadding }, (_, i) => {
40 | return (
41 |
42 |
49 |
50 | );
51 | });
52 |
53 | return (
54 |
55 | {leftSideRulers}
56 |
57 |
64 |
65 | {rightSideRulers}
66 |
67 |
68 | );
69 | }
70 |
71 | export default memo(SlotDial);
72 |
--------------------------------------------------------------------------------
/pkg/forky/db/frame_metadata.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/attestantio/go-eth2-client/spec/phase0"
7 | "github.com/ethpandaops/forky/pkg/forky/types"
8 | "gorm.io/gorm"
9 | )
10 |
11 | type FrameMetadata struct {
12 | gorm.Model
13 | ID string `gorm:"primaryKey"`
14 | Node string `gorm:"index"`
15 | // We have to use int64 here as SQLite doesn't support uint64. This sucks
16 | // but slot 9223372036854775808 is probably around the heat death
17 | // of the universe so we should be OK.
18 | WallClockSlot int64 `gorm:"index:idx_wall_clock_slot,where:deleted_at IS NULL"`
19 | WallClockEpoch int64
20 | FetchedAt time.Time `gorm:"index"`
21 | Labels []FrameMetadataLabel `gorm:"foreignkey:FrameID;"`
22 | ConsensusClient string `gorm:"not null;default:''"`
23 | EventSource EventSource `gorm:"not null;default:0"`
24 | }
25 |
26 | type FrameMetadatas []*FrameMetadata
27 |
28 | func (f *FrameMetadatas) AsFrameMetadata() []*types.FrameMetadata {
29 | frames := make([]*types.FrameMetadata, len(*f))
30 |
31 | for i, frame := range *f {
32 | frames[i] = frame.AsFrameMetadata()
33 | }
34 |
35 | return frames
36 | }
37 |
38 | func (f *FrameMetadata) AsFrameMetadata() *types.FrameMetadata {
39 | l := FrameMetadataLabels(f.Labels)
40 |
41 | return &types.FrameMetadata{
42 | ID: f.ID,
43 | Node: f.Node,
44 | //nolint:gosec // ignore integer overflow conversion uint64 -> int64
45 | WallClockSlot: phase0.Slot(f.WallClockSlot),
46 | //nolint:gosec // ignore integer overflow conversion uint64 -> int64
47 | WallClockEpoch: phase0.Epoch(f.WallClockEpoch),
48 | FetchedAt: f.FetchedAt,
49 | Labels: l.AsStrings(),
50 | ConsensusClient: f.ConsensusClient,
51 | EventSource: f.EventSource.String(),
52 | }
53 | }
54 |
55 | func (f *FrameMetadata) FromFrameMetadata(metadata *types.FrameMetadata) *FrameMetadata {
56 | f.ID = metadata.ID
57 | f.Node = metadata.Node
58 | //nolint:gosec // ignore integer overflow conversion uint64 -> int64
59 | f.WallClockSlot = int64(metadata.WallClockSlot)
60 | //nolint:gosec // ignore integer overflow conversion uint64 -> int64
61 | f.WallClockEpoch = int64(metadata.WallClockEpoch)
62 | f.FetchedAt = metadata.FetchedAt
63 |
64 | f.Labels = FrameMetadataLabels{}
65 |
66 | f.ConsensusClient = metadata.ConsensusClient
67 | f.EventSource = NewEventSourceFromType(types.EventSource(metadata.EventSource))
68 |
69 | for _, label := range metadata.Labels {
70 | f.Labels = append(f.Labels, FrameMetadataLabel{
71 | Name: label,
72 | FrameID: metadata.ID,
73 | })
74 | }
75 |
76 | return f
77 | }
78 |
--------------------------------------------------------------------------------
/web/src/components/Walker.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from 'react';
2 |
3 | interface WalkerProps {
4 | width?: number;
5 | height?: number;
6 | }
7 |
8 | const Walker: React.FC = ({
9 | width = window.innerWidth,
10 | height = window.innerHeight,
11 | }) => {
12 | const walkerCanvasRef = useRef(null);
13 |
14 | useEffect(() => {
15 | if (!walkerCanvasRef.current) return;
16 | const walkerCanvas = walkerCanvasRef.current;
17 | const walkerContext = walkerCanvas.getContext('2d');
18 | if (!walkerContext) return;
19 |
20 | walkerCanvas.width = width * 2;
21 | walkerCanvas.height = height * 2;
22 | walkerCanvas.style.width = `${width}px`;
23 | walkerCanvas.style.height = `${height}px`;
24 | walkerContext.scale(2, 2);
25 |
26 | const halfWidth = width;
27 | const halfHeight = height;
28 |
29 | walker();
30 |
31 | function walker() {
32 | const x = halfWidth / 2;
33 | const y = halfHeight / 2;
34 | const stepSize = 10;
35 | const walkerCount = 5;
36 | const angles = [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2];
37 | const colors = ['#241e44', '#25315e', '#3a5c85', '#56a1bf', '#97dbd2'];
38 |
39 | if (!walkerContext) return;
40 |
41 | for (let i = 0; i < walkerCount; i++) {
42 | walkingCircle(x, y, stepSize, i);
43 | }
44 |
45 | function walkingCircle(x: number, y: number, stepSize: number, color: number) {
46 | draw();
47 |
48 | function draw() {
49 | const angle = pick(angles);
50 | x += Math.cos(angle ?? 0) * stepSize;
51 | y += Math.sin(angle ?? 0) * stepSize;
52 |
53 | if (x < 0) x = 0;
54 | if (x > halfWidth) x = halfWidth;
55 | if (y < 0) y = 0;
56 | if (y > halfHeight) y = halfHeight;
57 |
58 | if (!walkerContext) return;
59 |
60 | walkerContext.beginPath();
61 | walkerContext.arc(x, y, 3, 0, Math.PI * 2, false);
62 | walkerContext.fillStyle = colors[color % colors.length];
63 | walkerContext.fill();
64 |
65 | requestAnimationFrame(draw);
66 | }
67 |
68 | function rangeFloor(min: number, max: number): number {
69 | return Math.floor(Math.random() * (max - min) + min);
70 | }
71 |
72 | function pick(array: T[]): T | undefined {
73 | if (array.length === 0) return undefined;
74 | return array[rangeFloor(0, array.length)];
75 | }
76 | }
77 | }
78 | }, [width, height]);
79 |
80 | return ;
81 | };
82 |
83 | export default Walker;
84 |
--------------------------------------------------------------------------------
/pkg/forky/service/metrics.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "runtime"
5 |
6 | "github.com/ethpandaops/forky/pkg/version"
7 | "github.com/prometheus/client_golang/prometheus"
8 | )
9 |
10 | type Metrics struct {
11 | namespace string
12 |
13 | info *prometheus.GaugeVec
14 | versionInfo *prometheus.GaugeVec
15 | retentionPeriod prometheus.Gauge
16 | operations *prometheus.CounterVec
17 | operationsErrors *prometheus.CounterVec
18 | }
19 |
20 | func NewMetrics(namespace string, config *Config, enabled bool) *Metrics {
21 | m := &Metrics{
22 | namespace: namespace,
23 |
24 | info: prometheus.NewGaugeVec(prometheus.GaugeOpts{
25 | Namespace: namespace,
26 | Name: "info",
27 | Help: "Information about the implementation of the service",
28 | }, []string{"version"}),
29 |
30 | versionInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
31 | Namespace: namespace,
32 | Name: "version_info",
33 | Help: "Information about the version of the service",
34 | }, []string{"short", "full", "full_with_goos", "git_commit", "go_version"}),
35 |
36 | retentionPeriod: prometheus.NewGauge(prometheus.GaugeOpts{
37 | Namespace: namespace,
38 | Name: "retention_period_seconds",
39 | Help: "The retention period of the service",
40 | }),
41 |
42 | operations: prometheus.NewCounterVec(prometheus.CounterOpts{
43 | Namespace: namespace,
44 | Name: "operations_count",
45 | Help: "The count of operations performed by the db",
46 | }, []string{"operation"}),
47 | operationsErrors: prometheus.NewCounterVec(prometheus.CounterOpts{
48 | Namespace: namespace,
49 | Name: "operations_errors_count",
50 | Help: "The count of operations performed by the db that resulted in an error",
51 | }, []string{"operation"}),
52 | }
53 |
54 | if enabled {
55 | prometheus.MustRegister(m.retentionPeriod)
56 | prometheus.MustRegister(m.operations)
57 | prometheus.MustRegister(m.operationsErrors)
58 | prometheus.MustRegister(m.info)
59 | prometheus.MustRegister(m.versionInfo)
60 | }
61 |
62 | m.retentionPeriod.Set(config.RetentionPeriod.Duration.Seconds())
63 |
64 | m.info.WithLabelValues(version.FullVWithGOOS()).Set(1)
65 | m.versionInfo.WithLabelValues(
66 | version.Short(),
67 | version.Full(),
68 | version.FullVWithGOOS(),
69 | version.GitCommit,
70 | runtime.Version(),
71 | ).Set(1)
72 |
73 | return m
74 | }
75 |
76 | func (m *Metrics) ObserveOperation(operation Operation) {
77 | m.operations.WithLabelValues(string(operation)).Inc()
78 | }
79 |
80 | func (m *Metrics) ObserveOperationError(operation Operation) {
81 | m.operationsErrors.WithLabelValues(string(operation)).Inc()
82 | }
83 |
--------------------------------------------------------------------------------
/pkg/forky/store/filesystem.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "os"
8 | "path/filepath"
9 |
10 | "github.com/ethpandaops/forky/pkg/forky/types"
11 | )
12 |
13 | type FileSystem struct {
14 | config FileSystemConfig
15 | opts *Options
16 | basicMetrics *BasicMetrics
17 | }
18 |
19 | type FileSystemConfig struct {
20 | BaseDir string `yaml:"base_dir"`
21 | }
22 |
23 | // NewFileSystem creates a new FileSystem instance with the specified base directory.
24 | func NewFileSystem(namespace string, config FileSystemConfig, opts *Options) (*FileSystem, error) {
25 | if config.BaseDir == "" {
26 | return nil, fmt.Errorf("base directory is required")
27 | }
28 |
29 | err := os.MkdirAll(config.BaseDir, 0o755)
30 | if err != nil {
31 | return nil, err
32 | }
33 |
34 | metrics := NewBasicMetrics(namespace, string(FileSystemStoreType), opts.MetricsEnabled)
35 |
36 | return &FileSystem{
37 | config: config,
38 | opts: opts,
39 | basicMetrics: metrics,
40 | }, nil
41 | }
42 |
43 | func (fs *FileSystem) framePath(id string) string {
44 | return filepath.Join(fs.config.BaseDir, fmt.Sprintf("%s.json.gz", id))
45 | }
46 |
47 | func (fs *FileSystem) SaveFrame(ctx context.Context, frame *types.Frame) error {
48 | data, err := frame.AsGzipJSON()
49 | if err != nil {
50 | return err
51 | }
52 |
53 | path := fs.framePath(frame.Metadata.ID)
54 |
55 | err = os.WriteFile(path, data, 0o600)
56 | if err != nil {
57 | return fmt.Errorf("failed to write frame to disk: %v", err.Error())
58 | }
59 |
60 | fs.basicMetrics.ObserveItemAdded(string(FrameDataType))
61 |
62 | return nil
63 | }
64 |
65 | func (fs *FileSystem) GetFrame(ctx context.Context, id string) (*types.Frame, error) {
66 | path := fs.framePath(id)
67 |
68 | data, err := os.ReadFile(path)
69 | if err != nil {
70 | return nil, err
71 | }
72 |
73 | var frame types.Frame
74 |
75 | err = frame.FromGzipJSON(data)
76 | if err != nil {
77 | if errors.Is(err, os.ErrNotExist) {
78 | return nil, ErrFrameNotFound
79 | }
80 |
81 | return nil, fmt.Errorf("failed to read frame from disk: %v", err.Error())
82 | }
83 |
84 | fs.basicMetrics.ObserveItemRetreived(string(FrameDataType))
85 |
86 | return &frame, nil
87 | }
88 |
89 | func (fs *FileSystem) DeleteFrame(ctx context.Context, id string) error {
90 | path := fs.framePath(id)
91 |
92 | err := os.Remove(path)
93 | if err != nil {
94 | if errors.Is(err, os.ErrNotExist) {
95 | return ErrFrameNotFound
96 | }
97 |
98 | return err
99 | }
100 |
101 | fs.basicMetrics.ObserveItemRemoved(string(FrameDataType))
102 |
103 | return nil
104 | }
105 |
--------------------------------------------------------------------------------
/web/src/hooks/useActive.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 |
3 | import { FrameMetaData } from '@app/types/api';
4 | import useFocus from '@contexts/focus';
5 | import { useMetadataQuery } from '@hooks/useQuery';
6 |
7 | interface State {
8 | nodes: string[];
9 | ids: string[];
10 | }
11 |
12 | export function findLatestFrameIdPerNode(
13 | focusedTime: number,
14 | metadata: FrameMetaData[] = [],
15 | focusedNode?: string,
16 | ): State {
17 | // Filter metadata
18 | metadata = metadata.filter(frame => {
19 | // only frames with fetched_at before focusedTime
20 | if (new Date(frame.fetched_at).getTime() >= focusedTime) return false;
21 |
22 | return true;
23 | });
24 |
25 | // Group metadata by node
26 | let groupedMetadata: { [key: string]: FrameMetaData[] } = {};
27 | for (const frame of metadata) {
28 | if (!groupedMetadata[frame.node]) {
29 | groupedMetadata[frame.node] = [];
30 | }
31 | groupedMetadata[frame.node].push(frame);
32 | }
33 |
34 | // Filter by focusedNode if it's set
35 | if (focusedNode) {
36 | const nodeMetadata = groupedMetadata[focusedNode] || [];
37 | groupedMetadata = { [focusedNode]: nodeMetadata };
38 | }
39 |
40 | // Sort frames within each group and select the latest frame id
41 | const latestFrameIds: State = { nodes: [], ids: [] };
42 | for (const node in groupedMetadata) {
43 | const latestFrame = groupedMetadata[node].sort((a, b) => {
44 | return new Date(b.fetched_at).getTime() - new Date(a.fetched_at).getTime();
45 | })[0];
46 |
47 | if (latestFrame) {
48 | latestFrameIds.nodes.push(node);
49 | latestFrameIds.ids.push(latestFrame.id);
50 | }
51 | }
52 |
53 | return latestFrameIds;
54 | }
55 |
56 | export default function useActiveFrame(): State {
57 | const {
58 | slot: focusedSlot,
59 | time: focusedTime,
60 | node: focusedNode,
61 | frameId: focusedFrameId,
62 | } = useFocus();
63 | const { data: metadataCurrent } = useMetadataQuery(
64 | {
65 | slot: focusedSlot,
66 | },
67 | !focusedFrameId,
68 | );
69 | const { data: metadataMinus1 } = useMetadataQuery(
70 | {
71 | slot: focusedSlot - 1,
72 | },
73 | !focusedFrameId,
74 | );
75 | const { data: metadataMinus2 } = useMetadataQuery(
76 | {
77 | slot: focusedSlot - 2,
78 | },
79 | !focusedFrameId,
80 | );
81 |
82 | return useMemo(() => {
83 | if (focusedFrameId) return { nodes: [], ids: [focusedFrameId] };
84 | return findLatestFrameIdPerNode(
85 | focusedTime,
86 | [...(metadataCurrent ?? []), ...(metadataMinus1 ?? []), ...(metadataMinus2 ?? [])],
87 | focusedNode,
88 | );
89 | }, [focusedFrameId, focusedTime, metadataCurrent, metadataMinus1, metadataMinus2, focusedNode]);
90 | }
91 |
--------------------------------------------------------------------------------
/pkg/forky/api/http/http.go:
--------------------------------------------------------------------------------
1 | package http
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/attestantio/go-eth2-client/spec/phase0"
7 | "github.com/ethpandaops/forky/pkg/forky/service"
8 | "github.com/ethpandaops/forky/pkg/forky/types"
9 | )
10 |
11 | // V1
12 | // // Ethereum
13 | type V1GetEthereumSpecRequest struct {
14 | }
15 |
16 | type EthereumSpec struct {
17 | SecondsPerSlot uint64 `json:"seconds_per_slot"`
18 | SlotsPerEpoch uint64 `json:"slots_per_epoch"`
19 | GenesisTime time.Time `json:"genesis_time"`
20 | }
21 | type V1GetEthereumSpecResponse struct {
22 | NetworkName string `json:"network_name"`
23 | Spec EthereumSpec `json:"spec"`
24 | }
25 |
26 | type V1GetEthereumNowRequest struct {
27 | }
28 |
29 | type V1GetEthereumNowResponse struct {
30 | Slot uint64 `json:"slot"`
31 | Epoch uint64 `json:"epoch"`
32 | }
33 |
34 | // // Metadata
35 | type V1MetadataListRequest struct {
36 | Filter *service.FrameFilter `json:"filter"`
37 | Pagination *service.PaginationCursor `json:"pagination"`
38 | }
39 |
40 | type V1MetadataListResponse struct {
41 | Frames []*types.FrameMetadata `json:"frames"`
42 | Pagination *service.PaginationResponse `json:"pagination"`
43 | }
44 |
45 | type V1MetadataListNodesRequest struct {
46 | Filter *service.FrameFilter `json:"filter"`
47 | Pagination *service.PaginationCursor `json:"pagination"`
48 | }
49 |
50 | type V1MetadataListNodesResponse struct {
51 | Nodes []string `json:"nodes"`
52 | Pagination *service.PaginationResponse `json:"pagination"`
53 | }
54 |
55 | type V1MetadataListSlotsRequest struct {
56 | Filter *service.FrameFilter `json:"filter"`
57 | Pagination *service.PaginationCursor `json:"pagination"`
58 | }
59 |
60 | type V1MetadataListSlotsResponse struct {
61 | Slots []phase0.Slot `json:"slots"`
62 | Pagination *service.PaginationResponse `json:"pagination"`
63 | }
64 |
65 | type V1MetadataListEpochsRequest struct {
66 | Filter *service.FrameFilter `json:"filter"`
67 | Pagination *service.PaginationCursor `json:"pagination"`
68 | }
69 |
70 | type V1MetadataListEpochsResponse struct {
71 | Epochs []phase0.Epoch `json:"epochs"`
72 | Pagination *service.PaginationResponse `json:"pagination"`
73 | }
74 |
75 | type V1MetadataListLabelsRequest struct {
76 | Filter *service.FrameFilter `json:"filter"`
77 | Pagination *service.PaginationCursor `json:"pagination"`
78 | }
79 |
80 | type V1MetadataListLabelsResponse struct {
81 | Labels []string `json:"labels"`
82 | Pagination *service.PaginationResponse `json:"pagination"`
83 | }
84 |
85 | // // Frames
86 | type V1GetFrameRequest struct {
87 | }
88 |
89 | type V1GetFrameResponse struct {
90 | Frame *types.Frame `json:"frame"`
91 | }
92 |
--------------------------------------------------------------------------------
/web/src/types/graph.ts:
--------------------------------------------------------------------------------
1 | import Graphology from 'graphology';
2 |
3 | import { V1GetFrameResponse, FrameMetaData, Checkpoint } from '@app/types/api';
4 |
5 | export interface ProcessedForkChoiceNode {
6 | slot: number;
7 | blockRoot: string;
8 | parentRoot?: string;
9 | validity: string;
10 | executionBlockHash: string;
11 | canonical: boolean;
12 | checkpoint?: 'justified' | 'finalized';
13 | weight: bigint;
14 | orphaned: boolean;
15 | }
16 |
17 | export type Graph = Graphology;
18 |
19 | export type WeightedGraph = Graphology<
20 | WeightedNodeAttributes,
21 | EdgeAttributes,
22 | WeightedGraphAttributes
23 | >;
24 | export type AggregatedGraph = Graphology<
25 | AggregatedNodeAttributes,
26 | EdgeAttributes,
27 | AggregatedGraphAttributes
28 | >;
29 |
30 | export interface NodeAttributes {
31 | canonical: boolean;
32 | slot: number;
33 | offset: number;
34 | blockRoot: string;
35 | }
36 |
37 | export interface EdgeAttributes {
38 | distance: number;
39 | }
40 |
41 | export interface GraphAttributes {
42 | slotStart: number;
43 | slotEnd: number;
44 | id: string;
45 | head?: string;
46 | type: 'aggregated' | 'weighted' | 'empty';
47 | }
48 |
49 | export interface WeightedGraphAttributes extends GraphAttributes {
50 | forks: number;
51 | }
52 |
53 | export interface AggregatedGraphAttributes extends GraphAttributes {
54 | nodes: {
55 | metadata: FrameMetaData;
56 | head?: WeightedNodeAttributes;
57 | justifiedCheckpoint?: Checkpoint;
58 | finalizedCheckpoint?: Checkpoint;
59 | forks: number;
60 | }[];
61 | }
62 |
63 | export interface WeightedNodeAttributes extends NodeAttributes {
64 | checkpoint?: 'finalized' | 'justified';
65 | validity: 'valid' | string;
66 | orphaned?: boolean;
67 | parentRoot?: string;
68 | weight: bigint;
69 | weightPercentageComparedToHeaviestNeighbor: number;
70 | }
71 |
72 | export interface AggregatedNodeAttributes extends NodeAttributes {
73 | checkpoints: { node: string; checkpoint: 'finalized' | 'justified' }[];
74 | validities: { node: string; validity: 'valid' | string }[];
75 | orphaned: string[];
76 | highestWeight: bigint;
77 | seenByNodes: string[];
78 | canonicalForNodes: string[];
79 | }
80 |
81 | export type ProcessedData = {
82 | frame: Required['frame']>;
83 | graph: WeightedGraph;
84 | };
85 |
86 | export interface OrphanReference {
87 | slot: number;
88 | nodeId: string;
89 | highestOffset: number;
90 | lowestOffset: number;
91 | }
92 |
93 | export interface ForkReference {
94 | slot: number;
95 | parentSlot: number;
96 | nodeId: string;
97 | height: number;
98 | lastSlot: number;
99 | }
100 |
--------------------------------------------------------------------------------
/web/src/components/Slot.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo, ReactNode } from 'react';
2 |
3 | import classNames from 'clsx';
4 |
5 | import { FrameMetaData } from '@app/types/api';
6 | import Ruler from '@components/Ruler';
7 | import SnapshotMarker from '@components/SnapshotMarker';
8 | import useEthereum from '@contexts/ethereum';
9 | import useFocus from '@contexts/focus';
10 | import useActive from '@hooks/useActive';
11 | import { useMetadataQuery, useFrameQueries } from '@hooks/useQuery';
12 |
13 | type Props = {
14 | subMarks: number;
15 | segments: number;
16 | slot: number;
17 | shouldFetch?: boolean;
18 | };
19 |
20 | function Slot({ subMarks, slot, shouldFetch = false, segments }: Props) {
21 | const { secondsPerSlot, genesisTime } = useEthereum();
22 | const { node } = useFocus();
23 | const { ids } = useActive();
24 |
25 | const slotStart = useMemo(() => {
26 | return genesisTime + slot * (secondsPerSlot * 1000);
27 | }, [genesisTime, slot, secondsPerSlot]);
28 |
29 | const { data, isLoading, error } = useMetadataQuery({ slot }, shouldFetch);
30 | // prefetch framess
31 | useFrameQueries(data?.map(frame => frame.id) ?? [], Boolean(data) && shouldFetch);
32 |
33 | const groupedMarkers = useMemo(() => {
34 | const groupSize = 100 / segments;
35 | const groups =
36 | data?.reduce>((acc, frame) => {
37 | if (node && frame.node !== node) return acc;
38 | const timeIntoSlot = new Date(frame.fetched_at).getTime() - slotStart;
39 |
40 | const leftPercentage = (timeIntoSlot / (secondsPerSlot * 1000)) * 100;
41 | const groupIndex = Math.floor(leftPercentage / groupSize);
42 | const group = groupIndex * groupSize + groupSize / 2;
43 | if (!acc[group]) {
44 | acc[group] = [];
45 | }
46 | acc[group].push(frame);
47 | return acc;
48 | }, {}) ?? {};
49 |
50 | return Object.entries(groups).map(([group, metadata]) => (
51 |
52 | ));
53 | }, [data, slotStart, secondsPerSlot, segments, node, ids]);
54 |
55 | return (
56 |
72 |
73 | {!isLoading && !error && groupedMarkers}
74 |
75 |
76 | );
77 | }
78 |
79 | export default React.memo(Slot);
80 |
--------------------------------------------------------------------------------
/pkg/forky/api/http/routing.go:
--------------------------------------------------------------------------------
1 | package http
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "net/http"
7 | "strings"
8 | "time"
9 |
10 | "github.com/julienschmidt/httprouter"
11 | "github.com/sirupsen/logrus"
12 | )
13 |
14 | func DeriveRegisteredPath(request *http.Request, ps httprouter.Params) string {
15 | registeredPath := request.URL.Path
16 | for _, param := range ps {
17 | registeredPath = strings.Replace(registeredPath, param.Value, fmt.Sprintf(":%s", param.Key), 1)
18 | }
19 |
20 | return registeredPath
21 | }
22 |
23 | func WrappedHandler(log logrus.FieldLogger, metrics *Metrics, handler func(ctx context.Context, r *http.Request, p httprouter.Params, contentType ContentType) (*Response, error)) httprouter.Handle {
24 | return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
25 | start := time.Now()
26 |
27 | contentType := NewContentTypeFromRequest(r)
28 | ctx := r.Context()
29 | registeredPath := DeriveRegisteredPath(r, p)
30 |
31 | log.WithFields(logrus.Fields{
32 | "method": r.Method,
33 | "path": r.URL.Path,
34 | "content_type": contentType,
35 | "accept": r.Header.Get("Accept"),
36 | }).Debug("Handling request")
37 |
38 | metrics.ObserveRequest(r.Method, registeredPath)
39 |
40 | response := &Response{}
41 |
42 | var err error
43 |
44 | defer func() {
45 | metrics.ObserveResponse(r.Method, registeredPath, fmt.Sprintf("%v", response.StatusCode), contentType.String(), time.Since(start))
46 | }()
47 |
48 | response, err = handler(ctx, r, p, contentType)
49 | if err != nil {
50 | if writeErr := WriteErrorResponse(w, err.Error(), response.StatusCode); writeErr != nil {
51 | log.WithError(writeErr).Error("Failed to write error response")
52 | }
53 |
54 | return
55 | }
56 |
57 | // Set headers before any potential content processing
58 | for header, value := range response.Headers {
59 | w.Header().Set(header, value)
60 | }
61 |
62 | // Special case: Check for raw multipart content (stored as _raw_content in ExtraData)
63 | if rawContent, ok := response.ExtraData["_raw_content"]; ok {
64 | if rawBytes, ok := rawContent.([]byte); ok {
65 | // Raw content handling - write directly to response
66 | w.WriteHeader(response.StatusCode)
67 |
68 | if _, err := w.Write(rawBytes); err != nil {
69 | log.WithError(err).Error("Failed to write raw content")
70 | }
71 |
72 | return
73 | }
74 | }
75 |
76 | // Standard flow - marshal response based on content type
77 | data, err := response.MarshalAs(contentType)
78 | if err != nil {
79 | if writeErr := WriteErrorResponse(w, err.Error(), http.StatusInternalServerError); writeErr != nil {
80 | log.WithError(writeErr).Error("Failed to write error response")
81 | }
82 |
83 | return
84 | }
85 |
86 | if err := WriteContentAwareResponse(w, data, contentType); err != nil {
87 | log.WithError(err).Error("Failed to write response")
88 | }
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/web/src/mocks/handlers.ts:
--------------------------------------------------------------------------------
1 | import { http, RequestHandler, HttpResponse } from 'msw';
2 |
3 | import {
4 | Response,
5 | V1GetEthereumNowResponse,
6 | V1GetEthereumSpecResponse,
7 | V1MetadataListResponse,
8 | V1MetadataListNodesResponse,
9 | V1GetFrameResponse,
10 | } from '@app/types/api';
11 | import { generateRandomForkChoiceData } from '@utils/api';
12 | import { BASE_URL } from '@utils/environment';
13 |
14 | export const networkName = 'goerli';
15 |
16 | export const spec: Required['spec'] = {
17 | seconds_per_slot: 12,
18 | slots_per_epoch: 32,
19 | genesis_time: '2021-03-23T14:00:00Z',
20 | };
21 |
22 | export function getNow(): Required {
23 | const slot = Math.floor(
24 | (Date.now() - new Date(spec.genesis_time).getTime()) / 1000 / spec.seconds_per_slot,
25 | );
26 |
27 | return {
28 | slot,
29 | epoch: Math.floor(slot / spec.slots_per_epoch),
30 | };
31 | }
32 |
33 | export const nodes: Required = {
34 | nodes: ['ams3-teku-001', 'syd1-lighthouse-001', 'syd1-prysm-001'],
35 | pagination: { total: 3 },
36 | };
37 |
38 | export const handlers: Array = [
39 | http.get(`${BASE_URL}api/v1/ethereum/now`, async () => {
40 | return HttpResponse.json({ data: getNow() });
41 | }),
42 | http.get(`${BASE_URL}api/v1/ethereum/spec`, () => {
43 | return HttpResponse.json({ data: { network_name: networkName, spec } });
44 | }),
45 | http.post(`${BASE_URL}api/v1/metadata/nodes`, () => {
46 | return HttpResponse.json({ data: nodes });
47 | }),
48 | http.post(`${BASE_URL}api/v1/metadata`, async () => {
49 | const { slot, epoch } = getNow();
50 | const data: Response = {
51 | data: {
52 | frames: [
53 | {
54 | id: 'bfe734bb-c986-4859-8b3e-44314ceca0b5',
55 | node: nodes.nodes[Math.floor(Math.random() * nodes.nodes.length)],
56 | fetched_at: new Date(
57 | new Date(spec.genesis_time).getTime() + slot * spec.seconds_per_slot * 1000,
58 | ).toISOString(),
59 | wall_clock_slot: slot,
60 | wall_clock_epoch: epoch,
61 | labels: [],
62 | },
63 | ],
64 | pagination: { total: 1 },
65 | },
66 | };
67 | return HttpResponse.json(data);
68 | }),
69 | http.get(`${BASE_URL}api/v1/frames/:id`, ({ params }) => {
70 | const id = Array.isArray(params.id) ? params.id[0] : params.id;
71 | const { slot, epoch } = getNow();
72 | const data: Response = {
73 | data: {
74 | frame: {
75 | data: generateRandomForkChoiceData(),
76 | metadata: {
77 | id,
78 | node: 'ams3-teku-001',
79 | fetched_at: new Date(
80 | new Date(spec.genesis_time).getTime() + slot * spec.seconds_per_slot * 1000,
81 | ).toISOString(),
82 | wall_clock_slot: slot,
83 | wall_clock_epoch: epoch,
84 | labels: [],
85 | },
86 | },
87 | },
88 | };
89 | return HttpResponse.json(data);
90 | }),
91 | ];
92 |
--------------------------------------------------------------------------------
/pkg/forky/store/metrics.go:
--------------------------------------------------------------------------------
1 | package store
2 |
3 | import "github.com/prometheus/client_golang/prometheus"
4 |
5 | type BasicMetrics struct {
6 | namespace string
7 |
8 | info *prometheus.GaugeVec
9 | itemsAdded *prometheus.CounterVec
10 | itemsRemoved *prometheus.CounterVec
11 | itemsRetreived *prometheus.CounterVec
12 | itemsStored *prometheus.GaugeVec
13 |
14 | cacheHit *prometheus.CounterVec
15 | cacheMiss *prometheus.CounterVec
16 | }
17 |
18 | func NewBasicMetrics(namespace, storeType string, enabled bool) *BasicMetrics {
19 | m := &BasicMetrics{
20 | namespace: namespace,
21 |
22 | info: prometheus.NewGaugeVec(prometheus.GaugeOpts{
23 | Namespace: namespace,
24 | Name: "info",
25 | Help: "Information about the implementation of the store",
26 | }, []string{"implementation"}),
27 |
28 | itemsAdded: prometheus.NewCounterVec(prometheus.CounterOpts{
29 | Namespace: namespace,
30 | Name: "items_added_count",
31 | Help: "Number of items added to the store",
32 | }, []string{"type"}),
33 | itemsRemoved: prometheus.NewCounterVec(prometheus.CounterOpts{
34 | Namespace: namespace,
35 | Name: "items_removed_count",
36 | Help: "Number of items removed from the store",
37 | }, []string{"type"}),
38 | itemsRetreived: prometheus.NewCounterVec(prometheus.CounterOpts{
39 | Namespace: namespace,
40 | Name: "items_retrieved_count",
41 | Help: "Number of items retreived from the store",
42 | }, []string{"type"}),
43 | itemsStored: prometheus.NewGaugeVec(prometheus.GaugeOpts{
44 | Namespace: namespace,
45 | Name: "items_stored_total",
46 | Help: "Number of items stored in the store",
47 | }, []string{"type"}),
48 | cacheHit: prometheus.NewCounterVec(prometheus.CounterOpts{
49 | Namespace: namespace,
50 | Name: "cache_hit_count",
51 | Help: "Number of cache hits",
52 | }, []string{"type"}),
53 | cacheMiss: prometheus.NewCounterVec(prometheus.CounterOpts{
54 | Namespace: namespace,
55 | Name: "cache_miss_count",
56 | Help: "Number of cache misses",
57 | }, []string{"type"}),
58 | }
59 |
60 | if enabled {
61 | prometheus.MustRegister(m.info)
62 | prometheus.MustRegister(m.itemsAdded)
63 | prometheus.MustRegister(m.itemsRemoved)
64 | prometheus.MustRegister(m.itemsRetreived)
65 | prometheus.MustRegister(m.itemsStored)
66 | prometheus.MustRegister(m.cacheHit)
67 | prometheus.MustRegister(m.cacheMiss)
68 | }
69 |
70 | m.info.WithLabelValues(storeType).Set(1)
71 |
72 | return m
73 | }
74 |
75 | func (m *BasicMetrics) ObserveItemAdded(itemType string) {
76 | m.itemsAdded.WithLabelValues(itemType).Inc()
77 | }
78 |
79 | func (m *BasicMetrics) ObserveItemRemoved(itemType string) {
80 | m.itemsRemoved.WithLabelValues(itemType).Inc()
81 | }
82 |
83 | func (m *BasicMetrics) ObserveItemRetreived(itemType string) {
84 | m.itemsRetreived.WithLabelValues(itemType).Inc()
85 | }
86 |
87 | func (m *BasicMetrics) ObserveItemStored(itemType string, count int) {
88 | m.itemsStored.WithLabelValues(itemType).Set(float64(count))
89 | }
90 |
91 | func (m *BasicMetrics) ObserveCacheHit(itemType string) {
92 | m.cacheHit.WithLabelValues(itemType).Inc()
93 | }
94 |
95 | func (m *BasicMetrics) ObserveCacheMiss(itemType string) {
96 | m.cacheMiss.WithLabelValues(itemType).Inc()
97 | }
98 |
--------------------------------------------------------------------------------
/.github/workflows/goreleaser.yaml:
--------------------------------------------------------------------------------
1 | name: goreleaser
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 |
8 | jobs:
9 | goreleaser:
10 | permissions:
11 | contents: write
12 | runs-on:
13 | - self-hosted-ghr
14 | - size-l-x64
15 | steps:
16 | - name: Checkout
17 | uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
18 | with:
19 | fetch-depth: 0
20 | ref: ${{ github.ref }}
21 | - name: Derive release suffix from tag (if it exists)
22 | run: |
23 | # Strip the 'refs/tags/' prefix
24 | TAG_NAME=${GITHUB_REF#refs/tags/}
25 |
26 | # Extract suffix from tag name after the last '-' (e.g., 'dencun' from 'v1.0.0-dencun')
27 | RELEASE_SUFFIX=${TAG_NAME##*-}
28 |
29 | # Check if the suffix is still a version pattern (e.g., 'v0.0.44'), in which case there's no suffix
30 | if [[ $RELEASE_SUFFIX =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
31 | RELEASE_SUFFIX=""
32 | fi
33 |
34 | echo "RELEASE_SUFFIX=$RELEASE_SUFFIX" >> $GITHUB_ENV
35 |
36 | echo "Release suffix: $RELEASE_SUFFIX"
37 |
38 | - name: Set up Go
39 | uses: actions/setup-go@6edd4406fa81c3da01a34fa6f6343087c207a568 # v3.5.0
40 | with:
41 | go-version: '1.24'
42 | - uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
43 | name: Set up Node
44 | with:
45 | node-version: 22
46 | - name: Run apt-get update
47 | run: sudo apt-get update
48 | - name: Install cross-compiler for linux/arm64
49 | run: sudo apt-get -y install gcc-aarch64-linux-gnu
50 | - name: Install make
51 | run: sudo apt-get -y install make
52 | - name: Set up QEMU
53 | uses: docker/setup-qemu-action@2b82ce82d56a2a04d2637cd93a637ae1b359c0a7 # v2.2.0
54 | - name: Set up Docker Context for Buildx
55 | shell: bash
56 | id: buildx-context
57 | run: |
58 | docker context create builders
59 | - name: Set up Docker Buildx
60 | uses: docker/setup-buildx-action@885d1462b80bc1c1c7f0b00334ad271f09369c55 # v2.10.0
61 | with:
62 | endpoint: builders
63 | - name: Login to DockerHub
64 | uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2.2.0
65 | with:
66 | username: ${{ secrets.DOCKERHUB_USERNAME }}
67 | password: ${{ secrets.DOCKERHUB_TOKEN }}
68 | - name: Update GoReleaser config
69 | run: |
70 | cp .goreleaser.yaml ../.goreleaser.yaml.new
71 |
72 | # If we have a RELEASE_SUFFIX, update the goreleaser config to not set
73 | # the release as the latest
74 | if [[ -n "$RELEASE_SUFFIX" ]]; then
75 | echo "release:" >> ../.goreleaser.yaml.new
76 | echo " prerelease: true" >> ../.goreleaser.yaml.new
77 | echo " make_latest: false" >> ../.goreleaser.yaml.new
78 | fi
79 | - name: Run GoReleaser
80 | uses: goreleaser/goreleaser-action@5fdedb94abba051217030cc86d4523cf3f02243d # v4.6.0
81 | with:
82 | distribution: goreleaser
83 | version: latest
84 | args: release --clean --config ../.goreleaser.yaml.new
85 | env:
86 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
87 | RELEASE_SUFFIX: ${{ env.RELEASE_SUFFIX }}
88 |
--------------------------------------------------------------------------------
/web/src/types/api.ts:
--------------------------------------------------------------------------------
1 | export interface Checkpoint {
2 | epoch: string;
3 | root: string;
4 | }
5 |
6 | export interface ForkChoiceNode {
7 | slot: string;
8 | block_root: string;
9 | parent_root: string;
10 | justified_epoch: string;
11 | finalized_epoch: string;
12 | weight: string;
13 | validity: string;
14 | execution_block_hash: string;
15 | extra_data?: Record;
16 | }
17 |
18 | export interface ForkChoiceData {
19 | justified_checkpoint?: Checkpoint;
20 | finalized_checkpoint?: Checkpoint;
21 | fork_choice_nodes?: ForkChoiceNode[];
22 | extra_data?: unknown;
23 | }
24 |
25 | /* REQUESTS */
26 | export interface FrameFilter {
27 | node?: string;
28 | before?: string;
29 | after?: string;
30 | slot?: number;
31 | epoch?: number;
32 | labels?: string[];
33 | consensus_client?: string;
34 | event_source?: 'unknown' | 'beacon_node' | 'xatu_polling' | 'xatu_reorg_event';
35 | }
36 |
37 | export interface PaginationCursor {
38 | limit?: number;
39 | offset?: number;
40 | order?: string;
41 | }
42 |
43 | export interface V1MetadataListSlotsRequest {
44 | filter?: FrameFilter;
45 | pagination?: PaginationCursor;
46 | }
47 |
48 | export interface V1MetadataListRequest {
49 | filter?: FrameFilter;
50 | pagination?: PaginationCursor;
51 | }
52 |
53 | export interface V1MetadataListNodesRequest {
54 | filter?: FrameFilter;
55 | pagination?: PaginationCursor;
56 | }
57 |
58 | export interface V1MetadataListEpochsRequest {
59 | filter?: FrameFilter;
60 | pagination?: PaginationCursor;
61 | }
62 |
63 | export interface V1MetadataListLabelsRequest {
64 | filter?: FrameFilter;
65 | pagination?: PaginationCursor;
66 | }
67 |
68 | /* RESPONSE */
69 | export interface Response {
70 | data?: T;
71 | }
72 |
73 | export interface Frame {
74 | data?: ForkChoiceData;
75 | metadata?: FrameMetaData;
76 | }
77 |
78 | export interface FrameMetaData {
79 | id: string;
80 | node: string;
81 | fetched_at: string;
82 | wall_clock_slot: number;
83 | wall_clock_epoch: number;
84 | labels?: string[] | null;
85 | consensus_client?: string | null;
86 | event_source?: string | null;
87 | }
88 |
89 | export interface EthereumSpec {
90 | seconds_per_slot: number;
91 | slots_per_epoch: number;
92 | genesis_time: string;
93 | }
94 |
95 | export interface PaginationResponse {
96 | total?: number;
97 | }
98 |
99 | export interface V1MetadataListResponse {
100 | frames?: FrameMetaData[];
101 | pagination?: PaginationResponse;
102 | }
103 |
104 | export interface V1GetEthereumSpecResponse {
105 | network_name: string;
106 | spec?: EthereumSpec;
107 | }
108 |
109 | export interface V1GetEthereumNowResponse {
110 | slot?: number;
111 | epoch?: number;
112 | }
113 |
114 | export interface V1MetadataListNodesResponse {
115 | nodes?: string[];
116 | pagination: PaginationResponse;
117 | }
118 |
119 | export interface V1MetadataListSlotsResponse {
120 | slots?: number[];
121 | pagination: PaginationResponse;
122 | }
123 |
124 | export interface V1MetadataListEpochsResponse {
125 | epochs?: number[];
126 | pagination: PaginationResponse;
127 | }
128 |
129 | export interface V1MetadataListLabelsResponse {
130 | labels?: string[];
131 | pagination: PaginationResponse;
132 | }
133 |
134 | export interface V1GetFrameResponse {
135 | frame?: Frame;
136 | }
137 |
--------------------------------------------------------------------------------
/web/src/components/SnapshotMarker.tsx:
--------------------------------------------------------------------------------
1 | import { memo, useMemo } from 'react';
2 |
3 | import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
4 | import classNames from 'clsx';
5 |
6 | import { FrameMetaData } from '@app/types/api';
7 | import useSelection from '@contexts/selection';
8 |
9 | function SnapshotMarker({
10 | metadata,
11 | activeIds,
12 | percentage,
13 | }: {
14 | metadata: FrameMetaData[];
15 | activeIds: string[];
16 | percentage: string;
17 | }) {
18 | const { setFrameId } = useSelection();
19 |
20 | const segments = useMemo(() => {
21 | const numberOfSegments = metadata.length;
22 | const segmentHeight = 100 / numberOfSegments;
23 | const segments = [];
24 | for (let i = 0; i < numberOfSegments; i++) {
25 | const isActive = activeIds.includes(metadata[i].id);
26 | const isReorg = metadata[i].event_source === 'xatu_reorg_event';
27 |
28 | let color = 'bg-sky-400 dark:bg-sky-700';
29 | if (isReorg) {
30 | color = 'dark:bg-amber-400 bg-amber-700';
31 | if (isActive) {
32 | color = 'bg-amber-600 dark:bg-amber-500';
33 | }
34 | } else if (isActive) {
35 | color = 'bg-sky-600 dark:bg-sky-500';
36 | }
37 |
38 | segments.push(
39 | ,
50 | );
51 | }
52 |
53 | return segments;
54 | }, [metadata, activeIds]);
55 |
56 | if (!segments.length) return null;
57 |
58 | if (segments.length === 1) {
59 | return (
60 |
66 | setFrameId(metadata[0].id)}
69 | >
70 | {segments}
71 |
72 |
73 | );
74 | }
75 |
76 | return (
77 |
84 |
85 | {segments}
86 |
87 |
88 | {metadata.map(meta => (
89 | setFrameId(meta.id)}
93 | >
94 |
{meta.id}
95 |
{meta.node}
96 |
97 | ))}
98 |
99 |
100 | );
101 | }
102 |
103 | export default memo(SnapshotMarker);
104 |
--------------------------------------------------------------------------------
/web/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 |
3 | import Loading from '@components/Loading';
4 | import { ValueProps } from '@contexts/ethereum';
5 | import { useSpecQuery, useNowQuery } from '@hooks/useQuery';
6 | import BYOFooter from '@parts/BYOFooter';
7 | import Events from '@parts/Events';
8 | import FrameFooter from '@parts/FrameFooter';
9 | import Header from '@parts/Header';
10 | import Selection from '@parts/Selection';
11 | import Stage from '@parts/Stage';
12 | import Timeline from '@parts/Timeline';
13 | import ApplicationProvider from '@providers/application';
14 |
15 | export default function App({
16 | node,
17 | frameId,
18 | byo = false,
19 | }: {
20 | node?: string;
21 | frameId?: string;
22 | byo?: boolean;
23 | }) {
24 | const { data, isLoading, error } = useSpecQuery();
25 | const { data: dataNow, isLoading: isLoadingNow, error: errorNow } = useNowQuery();
26 |
27 | const formattedData = useMemo(() => {
28 | if (!data?.spec) return undefined;
29 | const { seconds_per_slot, slots_per_epoch, genesis_time } = data.spec;
30 | if (!seconds_per_slot || !slots_per_epoch || !genesis_time) return undefined;
31 | return {
32 | secondsPerSlot: seconds_per_slot,
33 | slotsPerEpoch: slots_per_epoch,
34 | genesisTime: new Date(genesis_time).getTime(),
35 | networkName: data.network_name ?? 'Unknown',
36 | };
37 | }, [data]);
38 |
39 | const [initialTime, playing] = useMemo<[number | undefined, boolean]>(() => {
40 | // use the time from the url if it exists
41 | const time = new URLSearchParams(window.location.search).get('t');
42 | if (time) {
43 | const parsed = parseInt(time);
44 | if (!isNaN(parsed)) {
45 | return [parsed, false];
46 | }
47 | }
48 |
49 | // use the server time if it exists
50 | if (formattedData && dataNow) {
51 | return [
52 | formattedData.genesisTime +
53 | dataNow.slot * (formattedData.secondsPerSlot * 1000) -
54 | // offset minus 2 slots
55 | formattedData.secondsPerSlot * 1000 * 2,
56 | true,
57 | ];
58 | }
59 |
60 | // worst case when failing to get server now, just use the current local time
61 | return [errorNow ? Date.now() : undefined, true];
62 | }, [formattedData, dataNow, errorNow]);
63 |
64 | if (isLoading || error || !formattedData || !initialTime || isLoadingNow)
65 | return (
66 |
67 |
75 |
76 | );
77 |
78 | let footer = ;
79 | if (byo) {
80 | footer = ;
81 | } else if (frameId) {
82 | footer = ;
83 | }
84 |
85 | return (
86 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 | {footer}
99 |
100 |
101 |
102 | );
103 | }
104 |
--------------------------------------------------------------------------------
/web/src/components/EditableInput.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useRef, useEffect, HTMLAttributes, KeyboardEvent } from 'react';
2 |
3 | type InputType = 'text' | 'number' | 'datetime-local';
4 |
5 | type ValueType = {
6 | [key in InputType]: key extends 'text'
7 | ? string
8 | : key extends 'number' | 'datetime-local'
9 | ? number
10 | : never;
11 | };
12 |
13 | interface Props
14 | extends Omit, 'value' | 'onChange' | 'type'> {
15 | value: ValueType[T];
16 | onChange: (newValue: ValueType[T]) => void;
17 | type: T;
18 | }
19 |
20 | function EditableInput({ value, onChange, type, id }: Props) {
21 | const [isEditing, setIsEditing] = useState(false);
22 | const [inputValue, setInputValue] = useState(value);
23 |
24 | function formattedValue(value: ValueType[T]): string | number {
25 | if (type === 'datetime-local') {
26 | const now = new Date(value);
27 | now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
28 | return now.toISOString().slice(0, 19);
29 | }
30 | return value;
31 | }
32 |
33 | function parsedValue(value: string | number): ValueType[T] {
34 | if (type === 'datetime-local') {
35 | const now = new Date(value);
36 | return now.getTime() as ValueType[T];
37 | }
38 | if (type === 'number') {
39 | return Number.parseInt(`${value}`) as ValueType[T];
40 | }
41 | return value as ValueType[T];
42 | }
43 |
44 | const inputRef = useRef(null);
45 |
46 | useEffect(() => {
47 | if (isEditing && inputRef.current) {
48 | inputRef.current.focus();
49 | }
50 | }, [isEditing]);
51 |
52 | useEffect(() => {
53 | if (!isEditing) {
54 | setInputValue(value);
55 | }
56 | }, [value, isEditing]);
57 |
58 | const handleSave = () => {
59 | if (isEditing) {
60 | setIsEditing(false);
61 | if (inputValue !== value) {
62 | onChange(parsedValue(inputValue));
63 | }
64 | }
65 | };
66 |
67 | const handleKeyPress = (event: KeyboardEvent) => {
68 | if (event.key === 'Enter') {
69 | handleSave();
70 | }
71 | };
72 |
73 | return (
74 | <>
75 | {isEditing ? (
76 | setInputValue(e.target.value as unknown as ValueType[T])}
83 | onBlur={handleSave}
84 | step="1"
85 | onKeyDownCapture={handleKeyPress}
86 | />
87 | ) : (
88 | setIsEditing(true)}
96 | />
97 | )}
98 | >
99 | );
100 | }
101 |
102 | export default EditableInput;
103 |
--------------------------------------------------------------------------------
/pkg/forky/forkchoice.go:
--------------------------------------------------------------------------------
1 | package forky
2 |
3 | import (
4 | "context"
5 | "io/fs"
6 | "net/http"
7 | "time"
8 |
9 | "github.com/ethpandaops/forky/pkg/forky/api"
10 | "github.com/ethpandaops/forky/pkg/forky/service"
11 | static "github.com/ethpandaops/forky/web"
12 | "github.com/julienschmidt/httprouter"
13 | "github.com/prometheus/client_golang/prometheus/promhttp"
14 | "github.com/sirupsen/logrus"
15 | )
16 |
17 | // Server is the main server for the forkchoice service.
18 | // It glues together the service and the http api, while
19 | // also providing metrics and static file serving.
20 | type Server struct {
21 | log *logrus.Logger
22 | Cfg Config
23 |
24 | svc *service.ForkChoice
25 | http *api.HTTP
26 | }
27 |
28 | func NewServer(log *logrus.Logger, conf *Config) *Server {
29 | if err := conf.Validate(); err != nil {
30 | log.Fatalf("invalid config: %s", err)
31 | }
32 |
33 | // Create our service which will glue everything together.
34 | svc, err := service.NewForkChoice("forky", log, conf.Forky, service.DefaultOptions().SetMetricsEnabled(conf.Metrics.Enabled))
35 | if err != nil {
36 | log.Fatalf("failed to create service: %s", err)
37 | }
38 |
39 | // Create our HTTP API.
40 | apiOpts := api.DefaultOptions().SetMetricsEnabled(conf.Metrics.Enabled)
41 |
42 | h, err := api.NewHTTP(log, svc, conf.HTTP, apiOpts)
43 | if err != nil {
44 | log.Fatalf("failed to create http api: %s", err)
45 | }
46 |
47 | // Create our server.
48 | s := &Server{
49 | Cfg: *conf,
50 | log: log,
51 | svc: svc,
52 | http: h,
53 | }
54 |
55 | return s
56 | }
57 |
58 | func (s *Server) Start(ctx context.Context) error {
59 | if err := s.svc.Start(ctx); err != nil {
60 | return err
61 | }
62 |
63 | if s.Cfg.PProfAddr != nil {
64 | if err := s.ServePProf(ctx); err != nil {
65 | return err
66 | }
67 | }
68 |
69 | router := httprouter.New()
70 |
71 | frontend, err := fs.Sub(static.FS, "build/frontend")
72 | if err != nil {
73 | return err
74 | }
75 |
76 | filesystem := http.FS(frontend)
77 |
78 | router.NotFound = wrapHandler(http.FileServer(filesystem), filesystem)
79 |
80 | if s.Cfg.Metrics.Enabled {
81 | if err := s.ServeMetrics(ctx); err != nil {
82 | return err
83 | }
84 | }
85 |
86 | server := &http.Server{
87 | Addr: s.Cfg.ListenAddr,
88 | ReadHeaderTimeout: 3 * time.Minute,
89 | WriteTimeout: 15 * time.Minute,
90 | }
91 |
92 | if err := s.http.BindToRouter(ctx, router); err != nil {
93 | return err
94 | }
95 |
96 | server.Handler = router
97 |
98 | s.log.Infof("Serving http at %s", s.Cfg.ListenAddr)
99 |
100 | if err := server.ListenAndServe(); err != nil {
101 | s.log.Fatal(err)
102 | }
103 |
104 | return nil
105 | }
106 |
107 | func (s *Server) ServePProf(ctx context.Context) error {
108 | pprofServer := &http.Server{
109 | Addr: *s.Cfg.PProfAddr,
110 | ReadHeaderTimeout: 120 * time.Second,
111 | }
112 |
113 | go func() {
114 | s.log.Infof("Serving pprof at %s", *s.Cfg.PProfAddr)
115 |
116 | if err := pprofServer.ListenAndServe(); err != nil {
117 | s.log.Fatal(err)
118 | }
119 | }()
120 |
121 | return nil
122 | }
123 |
124 | func (s *Server) ServeMetrics(ctx context.Context) error {
125 | go func() {
126 | server := &http.Server{
127 | Addr: s.Cfg.Metrics.Addr,
128 | ReadHeaderTimeout: 15 * time.Second,
129 | }
130 |
131 | server.Handler = promhttp.Handler()
132 |
133 | s.log.Infof("Serving metrics at %s", s.Cfg.Metrics.Addr)
134 |
135 | if err := server.ListenAndServe(); err != nil {
136 | s.log.Fatal(err)
137 | }
138 | }()
139 |
140 | return nil
141 | }
142 |
--------------------------------------------------------------------------------
/web/src/contexts/selection.tsx:
--------------------------------------------------------------------------------
1 | import { useContext as reactUseContext, createContext, useState, useCallback } from 'react';
2 |
3 | import { ForkChoiceNode, FrameMetaData } from '@app/types/api';
4 | import { WeightedNodeAttributes, AggregatedNodeAttributes } from '@app/types/graph';
5 |
6 | export const Context = createContext(undefined);
7 |
8 | export default function useContext() {
9 | const context = reactUseContext(Context);
10 | if (context === undefined) {
11 | throw new Error('Selection context must be used within a Selection provider');
12 | }
13 | return context;
14 | }
15 |
16 | export type WeightedNode = {
17 | metadata: FrameMetaData;
18 | attributes: WeightedNodeAttributes;
19 | node: ForkChoiceNode;
20 | };
21 |
22 | export type AggregatedNode = {
23 | nodes: Record<
24 | string,
25 | {
26 | metadata: FrameMetaData;
27 | attributes: WeightedNodeAttributes;
28 | node: ForkChoiceNode;
29 | }
30 | >;
31 | attributes: AggregatedNodeAttributes;
32 | };
33 |
34 | export type FrameBlock = {
35 | frameId: string;
36 | blockRoot: string;
37 | };
38 |
39 | export type AggregatedFramesBlock = {
40 | frameIds: string[];
41 | blockRoot: string;
42 | };
43 |
44 | export interface State {
45 | frameId?: string;
46 | setFrameId: (id?: string) => void;
47 | aggregatedFrameIds?: string[];
48 | setAggregatedFrameIds: (ids?: string[]) => void;
49 | frameBlock?: FrameBlock;
50 | setFrameBlock: (block?: FrameBlock) => void;
51 | aggregatedFramesBlock?: AggregatedFramesBlock;
52 | setAggregatedFramesBlock: (block?: AggregatedFramesBlock) => void;
53 | clearAll: () => void;
54 | }
55 |
56 | export function useValue(): State {
57 | const [frameId, setFrameId] = useState();
58 | const [aggregatedFrameIds, setAggregatedFrameIds] = useState();
59 | const [frameBlock, setFrameBlock] = useState();
60 | const [aggregatedFramesBlock, setAggregatedFramesBlock] = useState<
61 | AggregatedFramesBlock | undefined
62 | >();
63 |
64 | const setFrameIdWrapper = useCallback((id?: string) => {
65 | setFrameId(id);
66 | setAggregatedFrameIds(undefined);
67 | setFrameBlock(undefined);
68 | setAggregatedFramesBlock(undefined);
69 | }, []);
70 |
71 | const setAggregatedFrameIdsWrapper = useCallback((ids?: string[]) => {
72 | setFrameId(undefined);
73 | setAggregatedFrameIds(ids);
74 | setFrameBlock(undefined);
75 | setAggregatedFramesBlock(undefined);
76 | }, []);
77 |
78 | const setFrameBlockWrapper = useCallback((data?: FrameBlock) => {
79 | setFrameId(undefined);
80 | setAggregatedFrameIds(undefined);
81 | setFrameBlock(data);
82 | setAggregatedFramesBlock(undefined);
83 | }, []);
84 |
85 | const setAggregatedFramesBlockWrapper = useCallback((data?: AggregatedFramesBlock) => {
86 | setFrameId(undefined);
87 | setAggregatedFrameIds(undefined);
88 | setFrameBlock(undefined);
89 | setAggregatedFramesBlock(data);
90 | }, []);
91 |
92 | const clearAll = useCallback(() => {
93 | setFrameId(undefined);
94 | setAggregatedFrameIds(undefined);
95 | setFrameBlock(undefined);
96 | setAggregatedFramesBlock(undefined);
97 | }, []);
98 |
99 | return {
100 | frameId,
101 | setFrameId: setFrameIdWrapper,
102 | aggregatedFrameIds,
103 | setAggregatedFrameIds: setAggregatedFrameIdsWrapper,
104 | frameBlock,
105 | setFrameBlock: setFrameBlockWrapper,
106 | aggregatedFramesBlock,
107 | setAggregatedFramesBlock: setAggregatedFramesBlockWrapper,
108 | clearAll,
109 | };
110 | }
111 |
--------------------------------------------------------------------------------
/web/src/components/TimeDrag.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useCallback, useRef, useEffect, memo, ReactNode } from 'react';
2 |
3 | import useFocus from '@contexts/focus';
4 | import usePointer from '@hooks/usePointer';
5 |
6 | const TimeDrag = ({ multiplier, children }: { multiplier: number; children?: ReactNode }) => {
7 | const { time: focusedTime, shiftTime: shiftFocusedTime, playing, stop: stopTimer } = useFocus();
8 | const [dragging, setDragging] = useState(false);
9 | const { up, x } = usePointer({ listen: true });
10 | const prevX = useRef(null);
11 | const lastPositions = useRef([]);
12 | const lastTimes = useRef([]);
13 | const velocityRef = useRef(0);
14 | const requestIdRef = useRef(null);
15 | const lastMoveTimeRef = useRef(performance.now());
16 |
17 | useEffect(() => {
18 | if (up) {
19 | setDragging(false);
20 | prevX.current = null;
21 | }
22 | }, [up]);
23 |
24 | const handlePointerUp = useCallback(() => {
25 | setDragging(false);
26 | prevX.current = null;
27 | }, []);
28 |
29 | useEffect(() => {
30 | if (playing) {
31 | velocityRef.current = 0;
32 | lastPositions.current = [];
33 | lastTimes.current = [];
34 | }
35 | }, [playing]);
36 |
37 | const updateFocusedTime = useCallback(() => {
38 | if (!playing) {
39 | const deltaTime = velocityRef.current;
40 | shiftFocusedTime(-deltaTime);
41 | velocityRef.current *= 0.9;
42 | }
43 |
44 | if (!playing && Math.abs(velocityRef.current) > 1) {
45 | requestIdRef.current = requestAnimationFrame(updateFocusedTime);
46 | } else {
47 | requestIdRef.current = null;
48 | }
49 | }, [shiftFocusedTime, playing]);
50 |
51 | useEffect(() => {
52 | if (prevX.current !== x && dragging && x !== null && prevX.current !== null) {
53 | const deltaX = x - prevX.current;
54 | shiftFocusedTime(-deltaX * multiplier);
55 |
56 | lastPositions.current.push(x);
57 | lastTimes.current.push(Date.now());
58 |
59 | if (lastPositions.current.length > 5) {
60 | lastPositions.current.shift();
61 | lastTimes.current.shift();
62 | }
63 | lastMoveTimeRef.current = performance.now();
64 | } else if (prevX.current !== x && !dragging && !playing && lastPositions.current.length > 0) {
65 | const lastDeltaX =
66 | lastPositions.current[lastPositions.current.length - 1] - lastPositions.current[0];
67 | const lastDeltaTime = lastTimes.current[lastTimes.current.length - 1] - lastTimes.current[0];
68 | const elapsedTimeSinceLastMove = performance.now() - lastMoveTimeRef.current;
69 | if (lastDeltaTime !== 0 && elapsedTimeSinceLastMove < 25) {
70 | const velocity = -(lastDeltaX / lastDeltaTime) * -1000;
71 | if (Math.abs(velocity) > 250) {
72 | velocityRef.current = velocity;
73 | requestIdRef.current = requestAnimationFrame(updateFocusedTime);
74 | }
75 | }
76 |
77 | lastPositions.current = [];
78 | lastTimes.current = [];
79 | }
80 | prevX.current = x;
81 | }, [dragging, x, focusedTime, updateFocusedTime, playing, multiplier, shiftFocusedTime]);
82 |
83 | const handlePointerDown = useCallback(() => {
84 | if (playing) stopTimer();
85 | lastPositions.current = [];
86 | lastTimes.current = [];
87 | velocityRef.current = 0;
88 | if (requestIdRef.current !== null) {
89 | cancelAnimationFrame(requestIdRef.current);
90 | requestIdRef.current = null;
91 | }
92 | setDragging(true);
93 | }, [playing, stopTimer]);
94 |
95 | return (
96 |
101 | {children}
102 |
103 | );
104 | };
105 |
106 | export default memo(TimeDrag);
107 |
--------------------------------------------------------------------------------
/web/src/components/WeightedNode.tsx:
--------------------------------------------------------------------------------
1 | import { useState, memo } from 'react';
2 |
3 | import classNames from 'clsx';
4 |
5 | import ProgressCircle from '@components/ProgressCircle';
6 |
7 | function WeightedNode({
8 | id,
9 | hash,
10 | weight,
11 | type,
12 | validity,
13 | x,
14 | y,
15 | radius,
16 | weightPercentageComparedToHeaviestNeighbor = 100,
17 | className,
18 | onClick,
19 | }: {
20 | id?: string;
21 | hash: string;
22 | weight: string;
23 | type: 'canonical' | 'fork' | 'finalized' | 'justified' | 'detached';
24 | validity: 'valid' | string;
25 | x: number;
26 | y: number;
27 | radius: number;
28 | weightPercentageComparedToHeaviestNeighbor?: number;
29 | className?: string;
30 | onClick?: (hash: string) => void;
31 | }) {
32 | const [isHighlighted, setIsHighlighted] = useState(false);
33 |
34 | const [color, backgroundColor, borderColor] = (() => {
35 | if (!['valid', 'optimistic'].includes(validity.toLowerCase())) {
36 | return ['text-red-600', 'text-red-800', 'border-red-500 dark:border-red-900'];
37 | }
38 | switch (type) {
39 | case 'canonical':
40 | if (validity.toLowerCase() === 'optimistic') {
41 | return ['text-yellow-600', 'text-yellow-800', 'border-yellow-500 dark:border-yellow-900'];
42 | }
43 | return [
44 | 'text-emerald-600',
45 | 'text-emerald-800',
46 | 'border-emerald-500 dark:border-emerald-900',
47 | ];
48 | case 'fork':
49 | return ['text-amber-600', 'text-amber-800', 'border-amber-500 dark:border-amber-900'];
50 | case 'finalized':
51 | return [
52 | 'text-fuchsia-600',
53 | 'text-fuchsia-800',
54 | 'border-fuchsia-500 dark:border-fuchsia-900',
55 | ];
56 | case 'justified':
57 | return ['text-indigo-600', 'text-indigo-800', 'border-indigo-500 dark:border-indigo-900'];
58 | case 'detached':
59 | return ['text-red-600', 'text-red-800', 'border-red-500 dark:border-red-900'];
60 | default:
61 | return [
62 | 'text-emerald-600',
63 | 'text-emerald-800',
64 | 'border-emerald-500 dark:border-emerald-900',
65 | ];
66 | }
67 | })();
68 |
69 | return (
70 | onClick?.(hash)}
86 | onMouseEnter={() => setIsHighlighted(true)}
87 | onMouseLeave={() => setIsHighlighted(false)}
88 | >
89 |
96 |
97 | {type === 'finalized' || type === 'justified' || type === 'detached'
98 | ? type.toUpperCase()
99 | : validity.toUpperCase()}
100 |
101 |
102 | {hash.substring(0, 6)}...{hash.substring(hash.length - 4)}
103 |
104 |
110 | {weight && weight !== '0' ? weight : '\u00a0'}
111 |
112 |
113 | );
114 | }
115 |
116 | export default memo(WeightedNode);
117 |
--------------------------------------------------------------------------------
/web/src/utils/__tests__/functions.test.ts:
--------------------------------------------------------------------------------
1 | import { randomHex, randomInt, randomBigInt } from '@utils/functions';
2 |
3 | describe('functions', () => {
4 | describe('randomHex', () => {
5 | it('should return a string of the specified size plus 2 characters for the "0x" prefix', () => {
6 | const size = 4;
7 | const result = randomHex(size);
8 | expect(typeof result).toBe('string');
9 | expect(result.length).toBe(size + 2);
10 | });
11 |
12 | it('should return a string starting with "0x" prefix', () => {
13 | const size = 6;
14 | const result = randomHex(size);
15 | expect(result.startsWith('0x')).toBe(true);
16 | });
17 |
18 | it('should return a string containing only hexadecimal characters', () => {
19 | const size = 8;
20 | const result = randomHex(size);
21 | const hexPattern = /^0x[a-fA-F0-9]+$/;
22 | expect(hexPattern.test(result)).toBe(true);
23 | });
24 |
25 | it('should return different strings for multiple calls', () => {
26 | const size = 12;
27 | const result1 = randomHex(size);
28 | const result2 = randomHex(size);
29 | expect(result1).not.toBe(result2);
30 | });
31 | });
32 |
33 | describe('randomInt', () => {
34 | it('should throw an error if min is greater than max', () => {
35 | const min = 10;
36 | const max = 5;
37 | expect(() => randomInt(min, max)).toThrow(
38 | 'Invalid range: min must be less than or equal to max',
39 | );
40 | });
41 |
42 | it('should return a number between min and max, inclusive', () => {
43 | const min = 1;
44 | const max = 10;
45 | const result = randomInt(min, max);
46 | expect(result).toBeGreaterThanOrEqual(min);
47 | expect(result).toBeLessThanOrEqual(max);
48 | });
49 |
50 | it('should return an integer', () => {
51 | const min = 3;
52 | const max = 15;
53 | const result = randomInt(min, max);
54 | expect(Number.isInteger(result)).toBe(true);
55 | });
56 |
57 | it('should return the same number if min and max are equal', () => {
58 | const min = 7;
59 | const max = 7;
60 | const result = randomInt(min, max);
61 | expect(result).toBe(min);
62 | });
63 |
64 | it('should return different numbers for multiple calls within the same range', () => {
65 | const min = 1;
66 | const max = 100;
67 | const result1 = randomInt(min, max);
68 | const result2 = randomInt(min, max);
69 | expect(result1).not.toBe(result2);
70 | });
71 | });
72 |
73 | describe('randomBigInt', () => {
74 | it('should throw an error if min is greater than max', () => {
75 | const min = BigInt(10);
76 | const max = BigInt(5);
77 | expect(() => randomBigInt(min, max)).toThrow(
78 | 'Invalid range: min must be less than or equal to max',
79 | );
80 | });
81 |
82 | it('should return a bigint between min and max, inclusive', () => {
83 | const min = BigInt(1);
84 | const max = BigInt(10);
85 | const result = randomBigInt(min, max);
86 | expect(result).toBeGreaterThanOrEqual(min);
87 | expect(result).toBeLessThanOrEqual(max);
88 | });
89 |
90 | it('should return the same bigint if min and max are equal', () => {
91 | const min = BigInt(7);
92 | const max = BigInt(7);
93 | const result = randomBigInt(min, max);
94 | expect(result).toBe(min);
95 | });
96 |
97 | it('should return different bigints for multiple calls within the same range', () => {
98 | const min = BigInt(1);
99 | const max = BigInt(100);
100 | const result1 = randomBigInt(min, max);
101 | const result2 = randomBigInt(min, max);
102 | expect(result1).not.toBe(result2);
103 | });
104 |
105 | it('should return a bigint within large range', () => {
106 | const min = BigInt('12345678901234567890');
107 | const max = BigInt('12345678909876543210');
108 | const result = randomBigInt(min, max);
109 | expect(result).toBeGreaterThanOrEqual(min);
110 | expect(result).toBeLessThanOrEqual(max);
111 | });
112 | });
113 | });
114 |
--------------------------------------------------------------------------------
/web/src/hooks/useGraph.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 |
3 | import Graphology from 'graphology';
4 |
5 | import {
6 | ProcessedData,
7 | Graph,
8 | NodeAttributes,
9 | EdgeAttributes,
10 | GraphAttributes,
11 | } from '@app/types/graph';
12 | import { aggregateProcessedData } from '@utils/graph';
13 |
14 | interface NodeData {
15 | id: string;
16 | x: number;
17 | y: number;
18 | attributes: NodeAttributes;
19 | }
20 |
21 | interface EdgeRelationship {
22 | x: number;
23 | y: number;
24 | id: string;
25 | }
26 |
27 | interface EdgeData {
28 | id: string;
29 | canonical: boolean;
30 | source: EdgeRelationship;
31 | target: EdgeRelationship;
32 | }
33 |
34 | interface OffsetData {
35 | minOffset: number;
36 | maxOffset: number;
37 | }
38 |
39 | function generateNodeData({
40 | graph,
41 | spacingX,
42 | spacingY,
43 | }: {
44 | graph: Graph;
45 | spacingX: number;
46 | spacingY: number;
47 | }): NodeData[] {
48 | return graph.mapNodes(node => {
49 | return {
50 | id: node,
51 | x:
52 | (graph.getNodeAttribute(node, 'slot') - graph.getAttribute('slotStart')) * spacingX +
53 | spacingX,
54 | y: graph.getNodeAttribute(node, 'offset') * spacingY - spacingY,
55 | attributes: graph.getNodeAttributes(node),
56 | };
57 | });
58 | }
59 |
60 | function generateEdgeData({
61 | graph,
62 | spacingX,
63 | spacingY,
64 | }: {
65 | graph: Graph;
66 | spacingX: number;
67 | spacingY: number;
68 | }): EdgeData[] {
69 | return graph.mapEdges(edge => {
70 | return {
71 | id: edge,
72 | canonical: graph.getSourceAttribute(edge, 'canonical'),
73 | source: {
74 | id: graph.getSourceAttribute(edge, 'blockRoot'),
75 | x:
76 | (graph.getSourceAttribute(edge, 'slot') - graph.getAttribute('slotStart')) * spacingX +
77 | spacingX,
78 | y: graph.getSourceAttribute(edge, 'offset') * spacingY - spacingY,
79 | },
80 | target: {
81 | id: graph.getTargetAttribute(edge, 'blockRoot'),
82 | x:
83 | (graph.getTargetAttribute(edge, 'slot') - graph.getAttribute('slotStart')) * spacingX +
84 | spacingX,
85 | y: graph.getTargetAttribute(edge, 'offset') * spacingY - spacingY,
86 | },
87 | };
88 | });
89 | }
90 |
91 | function generateOffsetData(graph: Graph): OffsetData {
92 | const minOffset =
93 | graph
94 | .mapNodes(node => graph.getNodeAttribute(node, 'offset'))
95 | .reduce((min, offset) => Math.min(min, offset), 0) ?? 0;
96 | const maxOffset =
97 | graph
98 | .mapNodes(node => graph.getNodeAttribute(node, 'offset'))
99 | .reduce((max, offset) => Math.max(max, offset), 0) ?? 0;
100 | return {
101 | minOffset,
102 | maxOffset,
103 | };
104 | }
105 |
106 | export default function useGraph({
107 | data,
108 | spacingX,
109 | spacingY,
110 | }: {
111 | data: ProcessedData[];
112 | spacingX: number;
113 | spacingY: number;
114 | }) {
115 | const { edges, nodes, offset, attributes, type } = useMemo<{
116 | edges: EdgeData[];
117 | nodes: NodeData[];
118 | offset: OffsetData;
119 | attributes: GraphAttributes;
120 | type: 'aggregated' | 'weighted' | 'concat' | 'empty';
121 | }>(() => {
122 | let graph: Graph;
123 | let type: 'aggregated' | 'weighted' | 'empty' = 'empty';
124 | if (!data.length) {
125 | const g = new Graphology();
126 | g.updateAttributes(() => ({
127 | slotStart: 0,
128 | slotEnd: 0,
129 | id: 'empty',
130 | type: 'empty',
131 | }));
132 | graph = g;
133 | } else if (data.length === 1) {
134 | graph = data[0].graph;
135 | type = 'weighted';
136 | } else {
137 | graph = aggregateProcessedData(data);
138 | type = 'aggregated';
139 | }
140 |
141 | return {
142 | edges: generateEdgeData({ graph, spacingX, spacingY }),
143 | nodes: generateNodeData({ graph, spacingX, spacingY }),
144 | offset: generateOffsetData(graph),
145 | attributes: graph.getAttributes(),
146 | type,
147 | };
148 | }, [spacingX, spacingY, data]);
149 |
150 | return { ...attributes, ...offset, edges, nodes, type };
151 | }
152 |
--------------------------------------------------------------------------------
/pkg/forky/api/http/response.go:
--------------------------------------------------------------------------------
1 | package http
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "net/http"
7 | )
8 |
9 | type ContentTypeResolver func() ([]byte, error)
10 | type ContentTypeResolvers map[ContentType]ContentTypeResolver
11 |
12 | type Response struct {
13 | resolvers ContentTypeResolvers
14 | StatusCode int `json:"status_code"`
15 | Headers map[string]string `json:"headers"`
16 | ExtraData map[string]interface{}
17 | }
18 | type jsonResponse struct {
19 | Data json.RawMessage `json:"data"`
20 | }
21 |
22 | func (r Response) MarshalAs(contentType ContentType) ([]byte, error) {
23 | if _, exists := r.resolvers[contentType]; !exists {
24 | return nil, fmt.Errorf("unsupported content-type: %s", contentType.String())
25 | }
26 |
27 | if contentType != ContentTypeJSON {
28 | return r.resolvers[contentType]()
29 | }
30 |
31 | return r.buildWrappedJSONResponse()
32 | }
33 |
34 | func (r Response) SetEtag(etag string) {
35 | r.Headers["ETag"] = etag
36 | }
37 |
38 | func (r Response) SetCacheControl(v string) {
39 | r.Headers["Cache-Control"] = v
40 | }
41 |
42 | func NewSuccessResponse(resolvers ContentTypeResolvers) *Response {
43 | return &Response{
44 | resolvers: resolvers,
45 | StatusCode: http.StatusOK,
46 | Headers: make(map[string]string),
47 | ExtraData: make(map[string]interface{}),
48 | }
49 | }
50 |
51 | func NewInternalServerErrorResponse(resolvers ContentTypeResolvers) *Response {
52 | return &Response{
53 | resolvers: resolvers,
54 | StatusCode: http.StatusInternalServerError,
55 | Headers: make(map[string]string),
56 | ExtraData: make(map[string]interface{}),
57 | }
58 | }
59 |
60 | func NewBadRequestResponse(resolvers ContentTypeResolvers) *Response {
61 | return &Response{
62 | resolvers: resolvers,
63 | StatusCode: http.StatusBadRequest,
64 | Headers: make(map[string]string),
65 | ExtraData: make(map[string]interface{}),
66 | }
67 | }
68 |
69 | func NewNotFoundResponse(resolvers ContentTypeResolvers) *Response {
70 | return &Response{
71 | resolvers: resolvers,
72 | StatusCode: http.StatusNotFound,
73 | Headers: make(map[string]string),
74 | ExtraData: make(map[string]interface{}),
75 | }
76 | }
77 |
78 | func NewUnsupportedMediaTypeResponse(resolvers ContentTypeResolvers) *Response {
79 | return &Response{
80 | resolvers: resolvers,
81 | StatusCode: http.StatusUnsupportedMediaType,
82 | Headers: make(map[string]string),
83 | ExtraData: make(map[string]interface{}),
84 | }
85 | }
86 |
87 | func (r *Response) AddExtraData(key string, value interface{}) {
88 | r.ExtraData[key] = value
89 | }
90 |
91 | func (r *Response) buildWrappedJSONResponse() ([]byte, error) {
92 | data, err := r.resolvers[ContentTypeJSON]()
93 | if err != nil {
94 | return nil, err
95 | }
96 |
97 | rsp := jsonResponse{
98 | Data: data,
99 | }
100 |
101 | return json.Marshal(rsp)
102 | }
103 |
104 | // WriteJSONResponse writes a JSON response to the given writer.
105 | func WriteJSONResponse(w http.ResponseWriter, data []byte) error {
106 | w.Header().Set("Content-Type", ContentTypeJSON.String())
107 |
108 | if _, err := w.Write(data); err != nil {
109 | return err
110 | }
111 |
112 | return nil
113 | }
114 |
115 | func WriteSSZResponse(w http.ResponseWriter, data []byte) error {
116 | w.Header().Set("Content-Type", ContentTypeSSZ.String())
117 |
118 | if _, err := w.Write(data); err != nil {
119 | return err
120 | }
121 |
122 | return nil
123 | }
124 |
125 | func WriteContentAwareResponse(w http.ResponseWriter, data []byte, contentType ContentType) error {
126 | switch contentType {
127 | case ContentTypeJSON:
128 | return WriteJSONResponse(w, data)
129 | case ContentTypeSSZ:
130 | return WriteSSZResponse(w, data)
131 | default:
132 | return WriteJSONResponse(w, data)
133 | }
134 | }
135 |
136 | func WriteErrorResponse(w http.ResponseWriter, msg string, statusCode int) error {
137 | w.Header().Set("Content-Type", ContentTypeJSON.String())
138 |
139 | w.WriteHeader(statusCode)
140 |
141 | bytes, err := json.Marshal(
142 | ErrorContainer{
143 | Message: msg,
144 | Code: statusCode,
145 | })
146 | if err != nil {
147 | return err
148 | }
149 |
150 | if _, err := w.Write(bytes); err != nil {
151 | return err
152 | }
153 |
154 | return nil
155 | }
156 |
--------------------------------------------------------------------------------
/.github/workflows/vet.yaml:
--------------------------------------------------------------------------------
1 | name: Vet
2 |
3 | on:
4 | pull_request:
5 | workflow_dispatch:
6 | branches: [ '**' ]
7 | permissions:
8 | contents: read
9 | checks: write
10 |
11 | jobs:
12 | golang-lint:
13 | runs-on: ubuntu-latest
14 | strategy:
15 | matrix:
16 | go_version: [ 1.24.x ]
17 | steps:
18 | - name: Set up Go ${{ matrix.go_version }}
19 | uses: actions/setup-go@6edd4406fa81c3da01a34fa6f6343087c207a568 # v3.5.0
20 | with:
21 | go-version: ${{ matrix.go_version }}
22 | - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
23 | - name: golangci-lint
24 | uses: golangci/golangci-lint-action@3a919529898de77ec3da873e3063ca4b10e7f5cc # v3.7.0
25 | with:
26 | # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
27 | version: v1.64
28 |
29 | # Optional: working directory, useful for monorepos
30 | # working-directory: somedir
31 |
32 | # Optional: golangci-lint command line arguments.
33 | args: --timeout=5m
34 |
35 | # Optional: show only new issues if it's a pull request. The default value is `false`.
36 | only-new-issues: true
37 |
38 | # Optional: if set to true then the all caching functionality will be complete disabled,
39 | # takes precedence over all other caching options.
40 | # skip-cache: true
41 |
42 | # Optional: if set to true then the action don't cache or restore ~/go/pkg.
43 | # skip-pkg-cache: true
44 |
45 | # Optional: if set to true then the action don't cache or restore ~/.cache/go-build.
46 | # skip-build-cache: true
47 | golang-test:
48 | runs-on: ubuntu-latest
49 | strategy:
50 | matrix:
51 | go_version: [ 1.24.x ]
52 | steps:
53 | - name: Set up Go ${{ matrix.go_version }}
54 | uses: actions/setup-go@6edd4406fa81c3da01a34fa6f6343087c207a568 # v3.5.0
55 | with:
56 | go-version: ${{ matrix.go_version }}
57 | - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
58 | - name: run tests
59 | run: go test -json ./... > test.json
60 | - name: Annotate tests
61 | if: always()
62 | uses: guyarb/golang-test-annotations@9ab2ea84a399d03ffd114bf49dd23ffadc794541 # v0.6.0
63 | with:
64 | test-results: test.json
65 | web-test:
66 | runs-on: ubuntu-latest
67 | defaults:
68 | run:
69 | working-directory: web
70 | strategy:
71 | matrix:
72 | node-version: [22.x]
73 | steps:
74 | - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
75 | - name: Set up NodeJS ${{ matrix.node-version }}
76 | uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
77 | with:
78 | node-version: ${{ matrix.node-version }}
79 | cache: "npm"
80 | cache-dependency-path: web/package-lock.json
81 | - name: Install dependencies
82 | run: npm ci
83 | - name: Test
84 | run: npm test
85 | web-lint:
86 | runs-on: ubuntu-latest
87 | defaults:
88 | run:
89 | working-directory: web
90 | strategy:
91 | matrix:
92 | node-version: [22.x]
93 | steps:
94 | - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
95 | - name: Set up NodeJS ${{ matrix.node-version }}
96 | uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
97 | with:
98 | node-version: ${{ matrix.node-version }}
99 | cache: "npm"
100 | cache-dependency-path: web/package-lock.json
101 | - name: Install dependencies
102 | run: npm ci
103 | - name: Lint
104 | run: npm run lint
105 | - name: Save Report
106 | run: npm run lint:report
107 | continue-on-error: true
108 | - name: Annotate Results
109 | uses: ataylorme/eslint-annotate-action@5f4dc2e3af8d3c21b727edb597e5503510b1dc9c # 2.2.0
110 | if: "!github.event.pull_request.head.repo.fork"
111 | with:
112 | repo-token: "${{ secrets.GITHUB_TOKEN }}"
113 | report-json: "web/eslint_report.json"
114 |
--------------------------------------------------------------------------------