├── 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 | 26 | 35 | 51 | 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 | --------------------------------------------------------------------------------