├── web ├── src │ ├── components │ │ ├── TabTypeStats.svelte │ │ ├── Status.svelte │ │ ├── InterestingMilAircraft.svelte │ │ ├── InterestingPolAircraft.svelte │ │ ├── InterestingCivAircraft.svelte │ │ ├── InterestingGovAircraft.svelte │ │ ├── TabMotionStats.svelte │ │ ├── TabInterestingStats.svelte │ │ ├── SkeletonMetrics.svelte │ │ ├── SkeletonRouteTable.svelte │ │ ├── TabRouteStats.svelte │ │ ├── MotionSlowestAircraft.svelte │ │ ├── MotionHighestAircraft.svelte │ │ ├── MotionLowestAircraft.svelte │ │ ├── MotionFastestAircraft.svelte │ │ ├── TabActivity.svelte │ │ ├── Footer.svelte │ │ ├── ActivityFlightsByPeriod.svelte │ │ ├── ActivityAircraftByPeriod.svelte │ │ ├── ThemeSelector.svelte │ │ ├── ActivityTopTypesByPeriod.svelte │ │ ├── RouteTopAirportsDomestic.svelte │ │ ├── RouteTopAirportsInternational.svelte │ │ ├── RouteTopCountriesDestination.svelte │ │ ├── RouteTopCountriesOrigin.svelte │ │ ├── RouteTopAirlines.svelte │ │ ├── RouteTopRoutes.svelte │ │ ├── MetricRoutes.svelte │ │ ├── MetricFlightsSeen.svelte │ │ ├── MetricAircraftSeen.svelte │ │ ├── MetricInteresting.svelte │ │ ├── MotionStats.svelte │ │ ├── charts │ │ │ ├── AircraftByPeriod.svelte │ │ │ └── FlightsByPeriod.svelte │ │ └── InterestingAircraft.svelte │ ├── app.css │ ├── vite-env.d.ts │ ├── lib │ │ └── themeStore.js │ ├── main.js │ ├── assets │ │ └── svelte.svg │ ├── stores │ │ └── settings.js │ └── App.svelte ├── public │ ├── favicon.ico │ ├── logo_icon.png │ └── vite.svg ├── app.css ├── postcss.config.js ├── svelte.config.js ├── .gitignore ├── index.html ├── vite.config.js ├── package.json ├── jsconfig.json └── README.md ├── rootfs └── etc │ ├── s6-overlay │ └── s6-rc.d │ │ ├── nginx │ │ ├── type │ │ └── run │ │ ├── user │ │ └── contents.d │ │ │ ├── nginx │ │ │ └── skystats │ │ └── skystats │ │ ├── type │ │ └── run │ └── nginx │ └── nginx.conf ├── migrations ├── 000003_add_user_settings.down.sql ├── 000004_increase_max_distance.up.sql ├── 000004_increase_max_distance.down.sql ├── 000006_add_user_setting_for_tags.down.sql ├── 000002_add_commit_hash.down.sql ├── 000002_add_commit_hash.up.sql ├── 000006_add_user_setting_for_tags.up.sql ├── 000005_change_aircraft_data_idx.up.sql ├── 000005_change_aircraft_data_idx.down.sql ├── 000001_initial_schema.down.sql ├── 000003_add_user_settings.up.sql └── 000001_initial_schema.up.sql ├── docs ├── logo │ ├── logo.jpg │ ├── logo_icon.png │ └── skystats_ascii.txt ├── setup │ └── aerial.jpg └── screenshots │ ├── 1_Home.png │ ├── MilGov.png │ ├── PolCiv.png │ ├── Stats.png │ ├── General2.png │ ├── Overlay.png │ ├── 3_RouteStats.png │ ├── 6_MotionStats.png │ ├── 2_AboveMeModal.png │ ├── 4_InterestingStats.png │ └── 5_InterestingModal.png ├── .gitignore ├── .env.example ├── .dockerignore ├── core ├── readsb.go ├── version.go ├── db-connector.go ├── db-utils.go ├── stats-motion-helpers.go ├── settings.go ├── db-migrations.go ├── core.go ├── registrations.go ├── models.go ├── stats-interesting.go ├── countries.go ├── db-plane-alert-data.go ├── routes.go └── stats-motion.go ├── .goreleaser.yaml ├── Dockerfile ├── example.compose.yml ├── data └── airlines_embed.go ├── go.mod ├── scripts └── build ├── .github └── workflows │ └── build.yml └── README.md /web/src/components/TabTypeStats.svelte: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rootfs/etc/s6-overlay/s6-rc.d/nginx/type: -------------------------------------------------------------------------------- 1 | longrun 2 | -------------------------------------------------------------------------------- /rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/nginx: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rootfs/etc/s6-overlay/s6-rc.d/skystats/type: -------------------------------------------------------------------------------- 1 | longrun 2 | -------------------------------------------------------------------------------- /rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/skystats: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "daisyui"; 3 | -------------------------------------------------------------------------------- /migrations/000003_add_user_settings.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS user_settings; -------------------------------------------------------------------------------- /docs/logo/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomcarman/skystats/HEAD/docs/logo/logo.jpg -------------------------------------------------------------------------------- /docs/setup/aerial.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomcarman/skystats/HEAD/docs/setup/aerial.jpg -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomcarman/skystats/HEAD/web/public/favicon.ico -------------------------------------------------------------------------------- /docs/logo/logo_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomcarman/skystats/HEAD/docs/logo/logo_icon.png -------------------------------------------------------------------------------- /web/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @plugin "daisyui" { 3 | themes: nord --default, night 4 | } -------------------------------------------------------------------------------- /web/public/logo_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomcarman/skystats/HEAD/web/public/logo_icon.png -------------------------------------------------------------------------------- /web/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /docs/screenshots/1_Home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomcarman/skystats/HEAD/docs/screenshots/1_Home.png -------------------------------------------------------------------------------- /docs/screenshots/MilGov.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomcarman/skystats/HEAD/docs/screenshots/MilGov.png -------------------------------------------------------------------------------- /docs/screenshots/PolCiv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomcarman/skystats/HEAD/docs/screenshots/PolCiv.png -------------------------------------------------------------------------------- /docs/screenshots/Stats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomcarman/skystats/HEAD/docs/screenshots/Stats.png -------------------------------------------------------------------------------- /docs/screenshots/General2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomcarman/skystats/HEAD/docs/screenshots/General2.png -------------------------------------------------------------------------------- /docs/screenshots/Overlay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomcarman/skystats/HEAD/docs/screenshots/Overlay.png -------------------------------------------------------------------------------- /docs/screenshots/3_RouteStats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomcarman/skystats/HEAD/docs/screenshots/3_RouteStats.png -------------------------------------------------------------------------------- /docs/screenshots/6_MotionStats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomcarman/skystats/HEAD/docs/screenshots/6_MotionStats.png -------------------------------------------------------------------------------- /migrations/000004_increase_max_distance.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE aircraft_data ALTER COLUMN last_seen_distance TYPE NUMERIC(7,2); -------------------------------------------------------------------------------- /docs/screenshots/2_AboveMeModal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomcarman/skystats/HEAD/docs/screenshots/2_AboveMeModal.png -------------------------------------------------------------------------------- /migrations/000004_increase_max_distance.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE aircraft_data ALTER COLUMN last_seen_distance TYPE NUMERIC(6,2); -------------------------------------------------------------------------------- /docs/screenshots/4_InterestingStats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomcarman/skystats/HEAD/docs/screenshots/4_InterestingStats.png -------------------------------------------------------------------------------- /docs/screenshots/5_InterestingModal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomcarman/skystats/HEAD/docs/screenshots/5_InterestingModal.png -------------------------------------------------------------------------------- /migrations/000006_add_user_setting_for_tags.down.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM user_settings WHERE setting_key = 'enable_planealertdb_tags'; 2 | -------------------------------------------------------------------------------- /rootfs/etc/s6-overlay/s6-rc.d/nginx/run: -------------------------------------------------------------------------------- 1 | #!/command/with-contenv sh 2 | exec s6wrap --prepend=nginx --timestamps --args /usr/sbin/nginx 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | skystats-daemon 3 | skystats.log 4 | skystats.pid 5 | .DS_Store 6 | .vscode 7 | .vite 8 | # Added by goreleaser init: 9 | dist/ 10 | -------------------------------------------------------------------------------- /rootfs/etc/s6-overlay/s6-rc.d/skystats/run: -------------------------------------------------------------------------------- 1 | #!/command/with-contenv sh 2 | cd /app/core 3 | exec s6wrap --prepend=skystats --timestamps --args /app/core/skystats 4 | -------------------------------------------------------------------------------- /migrations/000002_add_commit_hash.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE interesting_aircraft DROP COLUMN commit_hash; 2 | ALTER TABLE interesting_aircraft DROP CONSTRAINT interesting_aircraft_icao_unique; -------------------------------------------------------------------------------- /migrations/000002_add_commit_hash.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE interesting_aircraft ADD COLUMN commit_hash VARCHAR(40); 2 | ALTER TABLE interesting_aircraft ADD CONSTRAINT interesting_aircraft_icao_unique UNIQUE (icao); -------------------------------------------------------------------------------- /migrations/000006_add_user_setting_for_tags.up.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO user_settings (setting_key, setting_value, description) VALUES 2 | ('disable_planealertdb_tags', 'false', 'Disable plane alert database tags'); -------------------------------------------------------------------------------- /web/postcss.config.js: -------------------------------------------------------------------------------- 1 | import tailwindcss from '@tailwindcss/postcss' 2 | import autoprefixer from 'autoprefixer' 3 | 4 | export default { 5 | plugins: [ 6 | tailwindcss, 7 | autoprefixer, 8 | ], 9 | } -------------------------------------------------------------------------------- /web/src/lib/themeStore.js: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | 3 | export const themeChanged = writable(Date.now()); 4 | 5 | export function notifyThemeChange() { 6 | themeChanged.set(Date.now()); 7 | } -------------------------------------------------------------------------------- /web/src/main.js: -------------------------------------------------------------------------------- 1 | import { mount } from 'svelte' 2 | import '../app.css' 3 | import App from './App.svelte' 4 | 5 | const app = mount(App, { 6 | target: document.getElementById('app'), 7 | }) 8 | 9 | export default app 10 | -------------------------------------------------------------------------------- /web/src/components/Status.svelte: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 |
-------------------------------------------------------------------------------- /web/svelte.config.js: -------------------------------------------------------------------------------- 1 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' 2 | 3 | export default { 4 | // Consult https://svelte.dev/docs#compile-time-svelte-preprocess 5 | // for more information about preprocessors 6 | preprocess: vitePreprocess(), 7 | } 8 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | READSB_AIRCRAFT_JSON=http://yourhost:yourport/data/aircraft.json 2 | DB_HOST=skystats-db 3 | DB_PORT=5432 4 | DB_USER=user 5 | DB_PASSWORD=1234 6 | DB_NAME=skystats_db 7 | DOMESTIC_COUNTRY_ISO=US 8 | LAT=0.000000 9 | LON=0.000000 10 | RADIUS=500 11 | ABOVE_RADIUS=20 12 | LOG_LEVEL=INFO -------------------------------------------------------------------------------- /docs/logo/skystats_ascii.txt: -------------------------------------------------------------------------------- 1 | _____ __ __ __ 2 | / ___// /____ _______/ /_____ _/ /______ 3 | \__ \/ //_/ / / / ___/ __/ __ `/ __/ ___/ 4 | ___/ / ,< / /_/ (__ ) /_/ /_/ / /_(__ ) 5 | /____/_/|_|\__, /____/\__/\__,_/\__/____/ 6 | /____/ -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Environment files (only for local development) 2 | .env 3 | .env.* 4 | core/.env 5 | core/.env.* 6 | 7 | # Logs and runtime files 8 | *.log 9 | *.pid 10 | 11 | # Git files 12 | .git 13 | .gitignore 14 | 15 | # Documentation 16 | README.md 17 | docs/ 18 | !docs/logo/ 19 | 20 | # IDE files 21 | .vscode/ 22 | .idea/ 23 | *.swp 24 | *.swo 25 | *~ -------------------------------------------------------------------------------- /web/src/components/InterestingMilAircraft.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | -------------------------------------------------------------------------------- /web/src/components/InterestingPolAircraft.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | -------------------------------------------------------------------------------- /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 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /web/src/components/InterestingCivAircraft.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | -------------------------------------------------------------------------------- /web/src/components/InterestingGovAircraft.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 12 | -------------------------------------------------------------------------------- /core/readsb.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "os" 7 | ) 8 | 9 | func Fetch() ([]byte, error) { 10 | 11 | url := os.Getenv("READSB_AIRCRAFT_JSON") 12 | 13 | response, err := http.Get(url) 14 | 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | data, err := io.ReadAll(response.Body) 20 | 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | return data, nil 26 | } 27 | -------------------------------------------------------------------------------- /migrations/000005_change_aircraft_data_idx.up.sql: -------------------------------------------------------------------------------- 1 | -- Lower the threshold for when autovacuum runs, but increase the sleep time 2 | ALTER TABLE aircraft_data SET ( 3 | autovacuum_vacuum_scale_factor = 0.1, 4 | autovacuum_vacuum_cost_delay = 10 5 | ); 6 | 7 | -- Drop previous bloated / rarely used index 8 | DROP INDEX IF EXISTS idx_aircraft_data_hex_last_seen; 9 | 10 | -- Drop unused index 11 | DROP INDEX IF EXISTS aircraft_data_hex; -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Skystats 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /migrations/000005_change_aircraft_data_idx.down.sql: -------------------------------------------------------------------------------- 1 | -- Recreate the old indexes that were dropped 2 | CREATE INDEX IF NOT EXISTS idx_aircraft_data_hex_last_seen 3 | ON aircraft_data (hex, last_seen_epoch DESC); 4 | 5 | CREATE INDEX IF NOT EXISTS aircraft_data_hex 6 | ON aircraft_data (hex); 7 | 8 | -- Reset autovacuum settings to defaults 9 | ALTER TABLE aircraft_data RESET ( 10 | autovacuum_vacuum_scale_factor, 11 | autovacuum_vacuum_cost_delay 12 | ); 13 | -------------------------------------------------------------------------------- /migrations/000001_initial_schema.down.sql: -------------------------------------------------------------------------------- 1 | -- Drop all tables created in the up migration 2 | DROP TABLE IF EXISTS slowest_aircraft; 3 | DROP TABLE IF EXISTS lowest_aircraft; 4 | DROP TABLE IF EXISTS interesting_aircraft_seen; 5 | DROP TABLE IF EXISTS interesting_aircraft; 6 | DROP TABLE IF EXISTS highest_aircraft; 7 | DROP TABLE IF EXISTS route_data; 8 | DROP TABLE IF EXISTS fastest_aircraft; 9 | DROP TABLE IF EXISTS registration_data; 10 | DROP TABLE IF EXISTS aircraft_data; -------------------------------------------------------------------------------- /core/version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | ) 8 | 9 | // injected by goreleaser on build 10 | var ( 11 | version = "dev" 12 | commit = "none" 13 | date = "unknown" 14 | showVersion bool 15 | ) 16 | 17 | func showVersionExit() { 18 | fmt.Printf("skystats %s (commit %s) build %s\n", version, commit, date) 19 | os.Exit(0) 20 | } 21 | 22 | func init() { 23 | flag.BoolVar(&showVersion, "version", false, "display version") 24 | flag.BoolVar(&showVersion, "v", false, "display version") 25 | } 26 | -------------------------------------------------------------------------------- /web/src/components/TabMotionStats.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 |
9 | 10 | 11 | 12 | 13 |
-------------------------------------------------------------------------------- /web/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import { svelte } from '@sveltejs/vite-plugin-svelte' 3 | 4 | // https://vite.dev/config/ 5 | export default defineConfig({ 6 | base: './', 7 | plugins: [svelte()], 8 | server: { 9 | allowedHosts: true, // allow any host to access this page 10 | proxy: { 11 | '/api': { 12 | target: process.env.NODE_ENV === 'development' && process.env.DOCKER_ENV ? 13 | 'http://skystats-api:8080' : 'http://localhost:8080', 14 | changeOrigin: true, 15 | } 16 | } 17 | } 18 | }) 19 | -------------------------------------------------------------------------------- /web/src/components/TabInterestingStats.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 |
9 | 10 | 11 | 12 | 13 |
-------------------------------------------------------------------------------- /web/src/components/SkeletonMetrics.svelte: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
-------------------------------------------------------------------------------- /migrations/000003_add_user_settings.up.sql: -------------------------------------------------------------------------------- 1 | -- Create user_settings table 2 | CREATE TABLE user_settings ( 3 | id SERIAL PRIMARY KEY, 4 | setting_key VARCHAR(100) NOT NULL UNIQUE, 5 | setting_value TEXT NOT NULL, 6 | description TEXT 7 | ); 8 | 9 | -- Create index 10 | CREATE INDEX idx_user_settings_key ON user_settings(setting_key); 11 | 12 | -- Insert defaults 13 | INSERT INTO user_settings (setting_key, setting_value, description) VALUES 14 | ('route_table_limit', '5', 'Number of rows to display in Route Information tables'), 15 | ('interesting_table_limit', '5', 'Number of rows to display in Interesting Aircraft tables'), 16 | ('record_holder_table_limit', '5', 'Number of rows to display in Record Holder tables'); -------------------------------------------------------------------------------- /web/src/components/SkeletonRouteTable.svelte: -------------------------------------------------------------------------------- 1 |
    2 |
  • 3 |
    4 |
  • 5 | {#each Array(5) as _, index} 6 |
  • 7 |
    8 |
    9 |
    10 |
    11 |
    12 |
    13 |
    14 |
    15 |
    16 |
    17 |
    18 |
  • 19 | {/each} 20 |
-------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "devDependencies": { 12 | "@number-flow/svelte": "^0.3.9", 13 | "@sveltejs/vite-plugin-svelte": "^5.0.3", 14 | "@tailwindcss/postcss": "^4.1.11", 15 | "autoprefixer": "^10.4.21", 16 | "daisyui": "^5.0.46", 17 | "postcss": "^8.5.6", 18 | "svelte": "^5.28.1", 19 | "tailwindcss": "^4.1.11", 20 | "vite": "^6.3.5" 21 | }, 22 | "dependencies": { 23 | "@tabler/icons-svelte": "^3.34.0", 24 | "chart.js": "^4.5.0", 25 | "chartjs-adapter-date-fns": "^3.0.0", 26 | "flag-icons": "^7.5.0", 27 | "theme-change": "^2.5.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /web/src/components/TabRouteStats.svelte: -------------------------------------------------------------------------------- 1 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | -------------------------------------------------------------------------------- /web/src/components/MotionSlowestAircraft.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | 28 | -------------------------------------------------------------------------------- /web/src/components/MotionHighestAircraft.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | 28 | -------------------------------------------------------------------------------- /web/src/components/MotionLowestAircraft.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | 28 | -------------------------------------------------------------------------------- /web/src/components/MotionFastestAircraft.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 | 29 | -------------------------------------------------------------------------------- /web/src/components/TabActivity.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |
10 |
11 | 12 |
13 |
14 | 15 |
16 |
17 | 18 |
19 |
20 | 21 |
22 |
23 | 24 |
25 |
26 | 27 |
28 |
29 | -------------------------------------------------------------------------------- /core/db-connector.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "sync" 7 | 8 | "github.com/jackc/pgx/v5/pgxpool" 9 | "github.com/rs/zerolog/log" 10 | ) 11 | 12 | type postgres struct { 13 | db *pgxpool.Pool 14 | } 15 | 16 | var ( 17 | pgInstance *postgres 18 | pgOnce sync.Once 19 | ) 20 | 21 | func NewPG(ctx context.Context, connString string) (*postgres, error) { 22 | pgOnce.Do(func() { 23 | db, err := pgxpool.New(ctx, connString) 24 | if err != nil { 25 | log.Error().Err(err).Msg("Unable to connect to database") 26 | } 27 | 28 | pgInstance = &postgres{db} 29 | }) 30 | 31 | return pgInstance, nil 32 | } 33 | 34 | func (pg *postgres) Ping(ctx context.Context) error { 35 | return pg.db.Ping(ctx) 36 | } 37 | 38 | func (pg *postgres) Close() { 39 | pg.db.Close() 40 | } 41 | 42 | func GetConnectionUrl() string { 43 | 44 | return "postgres://" + 45 | os.Getenv("DB_USER") + ":" + 46 | os.Getenv("DB_PASSWORD") + "@" + 47 | os.Getenv("DB_HOST") + ":" + 48 | os.Getenv("DB_PORT") + "/" + 49 | os.Getenv("DB_NAME") 50 | } 51 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | 7 | builds: 8 | - id: skystats 9 | main: ./core 10 | binary: skystats 11 | env: 12 | - CGO_ENABLED=0 13 | ldflags: 14 | - -s -w -X main.version={{.Version}} -X main.commit={{.ShortCommit}} -X main.date={{.Date}} 15 | goos: 16 | - linux 17 | - windows 18 | - darwin 19 | goarch: 20 | - amd64 21 | - arm64 22 | 23 | archives: 24 | - id: default 25 | ids: [skystats] 26 | formats: [tar.gz] 27 | format_overrides: 28 | - goos: windows 29 | formats: [zip] 30 | name_template: >- 31 | {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }} 32 | 33 | checksum: 34 | 35 | changelog: 36 | use: github 37 | filters: 38 | exclude: 39 | - "^docs?:" 40 | - "^chore:" 41 | - "^test:" 42 | - "^ci:" 43 | - "merge pull request" 44 | - "merge branch" 45 | - "Merge pull request" 46 | 47 | release: 48 | draft: false 49 | prerelease: auto 50 | make_latest: auto 51 | -------------------------------------------------------------------------------- /web/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "bundler", 4 | "target": "ESNext", 5 | "module": "ESNext", 6 | /** 7 | * svelte-preprocess cannot figure out whether you have 8 | * a value or a type, so tell TypeScript to enforce using 9 | * `import type` instead of `import` for Types. 10 | */ 11 | "verbatimModuleSyntax": true, 12 | "isolatedModules": true, 13 | "resolveJsonModule": true, 14 | /** 15 | * To have warnings / errors of the Svelte compiler at the 16 | * correct position, enable source maps by default. 17 | */ 18 | "sourceMap": true, 19 | "esModuleInterop": true, 20 | "skipLibCheck": true, 21 | /** 22 | * Typecheck JS in `.svelte` and `.js` files by default. 23 | * Disable this if you'd like to use dynamic types. 24 | */ 25 | "checkJs": false 26 | }, 27 | /** 28 | * Use global.d.ts instead of compilerOptions.types 29 | * to avoid limiting type declarations. 30 | */ 31 | "include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"] 32 | } 33 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build the app 2 | FROM golang:1.25.3-alpine AS builder 3 | WORKDIR /app 4 | COPY go.mod go.sum /app 5 | COPY core /app/core 6 | COPY data /app/data 7 | COPY docs /app/docs 8 | RUN go mod download 9 | ARG VERSION=dev 10 | ARG COMMIT=none 11 | ARG DATE=unknown 12 | RUN DATE="${DATE:-$(date -u +'%Y-%m-%dT%H:%M:%SZ')}" && \ 13 | go build -ldflags "-s -w -X main.version=${VERSION:-dev} -X main.commit=${COMMIT:-none} -X main.date=${DATE}" -o skystats ./core 14 | 15 | 16 | FROM node:20-alpine AS node 17 | 18 | COPY ./web /app 19 | 20 | SHELL ["sh", "-o", "pipefail", "-c", "-x"] 21 | WORKDIR /app 22 | RUN \ 23 | npm install && \ 24 | npm run build 25 | 26 | LABEL org.opencontainers.image.source="https://github.com/tomcarman/skystats" 27 | FROM ghcr.io/sdr-enthusiasts/docker-baseimage:base 28 | #SHELL ["/bin/bash", "-o", "pipefail", "-c", "-x"] 29 | 30 | ENV \ 31 | S6_KILL_GRACETIME=100 \ 32 | API_PORT=8080 \ 33 | DOCKER_ENV=true 34 | 35 | COPY --from=node /app/dist /app/dist 36 | COPY --from=builder /app/skystats /app/core/skystats 37 | COPY --from=builder /app/docs/logo /app/docs/logo 38 | COPY migrations /app/migrations 39 | 40 | COPY rootfs/ / 41 | -------------------------------------------------------------------------------- /example.compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | skystats: 3 | image: ghcr.io/tomcarman/skystats:latest 4 | depends_on: 5 | skystats-db: 6 | condition: service_healthy 7 | ports: 8 | - "5173:80" 9 | container_name: skystats 10 | hostname: skystats 11 | environment: 12 | # ADSB 13 | - READSB_AIRCRAFT_JSON=${READSB_AIRCRAFT_JSON} 14 | # PostgreSQL 15 | - DB_HOST=${DB_HOST} 16 | - DB_PORT=${DB_PORT} 17 | - DB_USER=${DB_USER} 18 | - DB_PASSWORD=${DB_PASSWORD} 19 | - DB_NAME=${DB_NAME} 20 | # App 21 | - LAT=${LAT} 22 | - LON=${LON} 23 | - RADIUS=${RADIUS} 24 | - ABOVE_RADIUS=${ABOVE_RADIUS} 25 | - DOMESTIC_COUNTRY_ISO=${DOMESTIC_COUNTRY_ISO} 26 | - LOG_LEVEL=${LOG_LEVEL} 27 | 28 | skystats-db: 29 | image: postgres:17 30 | container_name: skystats-db 31 | environment: 32 | - POSTGRES_USER=${DB_USER} 33 | - POSTGRES_PASSWORD=${DB_PASSWORD} 34 | - POSTGRES_DB=${DB_NAME} 35 | volumes: 36 | - ./postgres_data:/var/lib/postgresql/data 37 | healthcheck: 38 | test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] 39 | interval: 5s 40 | timeout: 5s 41 | retries: 10 42 | start_period: 10s 43 | -------------------------------------------------------------------------------- /web/src/components/Footer.svelte: -------------------------------------------------------------------------------- 1 | 30 | 31 |
32 | {#if loading} 33 |
Loading...
34 | {:else if error} 35 |
Error: {error}
36 | {:else if data.version === "dev"} 37 |
{data.version} • {data.commit} • {data.date.toLocaleString()}
38 | {:else} 39 |
Skystats • {data.version}
40 | {/if} 41 |
-------------------------------------------------------------------------------- /web/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/components/ActivityFlightsByPeriod.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 |
8 |
9 |

Flights Seen

10 |
11 | 20 | 28 | 36 |
37 |
38 | {#if selectedTab === 0} 39 | 40 | {:else if selectedTab === 1} 41 | 42 | {:else if selectedTab === 2} 43 | 44 | {/if} 45 |
46 | 47 | 48 |
49 | -------------------------------------------------------------------------------- /web/src/components/ActivityAircraftByPeriod.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 |
8 |
9 |

Aircraft Seen

10 |
11 | 20 | 28 | 36 |
37 |
38 | {#if selectedTab === 0} 39 | 40 | {:else if selectedTab === 1} 41 | 42 | {:else if selectedTab === 2} 43 | 44 | {/if} 45 |
46 | 47 | 48 |
49 | -------------------------------------------------------------------------------- /web/src/components/ThemeSelector.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 | -------------------------------------------------------------------------------- /core/db-utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jackc/pgx/v5" 7 | "github.com/rs/zerolog/log" 8 | ) 9 | 10 | func MarkProcessed(pg *postgres, colName string, aircrafts []Aircraft) { 11 | 12 | batch := &pgx.Batch{} 13 | 14 | for _, aircraft := range aircrafts { 15 | updateStatement := `UPDATE aircraft_data SET ` + colName + ` = true WHERE id = $1` 16 | batch.Queue(updateStatement, aircraft.Id) 17 | } 18 | 19 | br := pg.db.SendBatch(context.Background(), batch) 20 | defer br.Close() 21 | 22 | for i := 0; i < len(aircrafts); i++ { 23 | _, err := br.Exec() 24 | if err != nil { 25 | log.Error().Err(err).Msg("MarkProcessed() - Unable to update data") 26 | } 27 | } 28 | } 29 | 30 | func DeleteExcessRows(pg *postgres, tableName string, metricName string, sortOrder string, maxRows int) { 31 | 32 | queryCount := `SELECT COUNT(*) FROM ` + tableName 33 | 34 | var rowCount int 35 | err := pg.db.QueryRow(context.Background(), queryCount).Scan(&rowCount) 36 | if err != nil { 37 | log.Error().Err(err).Msg("DeleteExcessRows() - Error querying db in DeleteExcessRows()") 38 | return 39 | } 40 | 41 | if rowCount > maxRows { 42 | 43 | excessRows := rowCount - maxRows 44 | 45 | if excessRows <= 0 { 46 | log.Debug().Msgf("DeleteExcessRows() - No excess rows in %s", tableName) 47 | return 48 | } 49 | 50 | deleteStatement := `DELETE FROM ` + tableName + ` 51 | WHERE id IN ( 52 | SELECT id 53 | FROM ` + tableName + ` 54 | ORDER BY ` + metricName + ` ` + sortOrder + ` , first_seen ASC 55 | LIMIT $1 56 | )` 57 | 58 | _, err := pg.db.Exec(context.Background(), deleteStatement, excessRows) 59 | if err != nil { 60 | log.Error().Err(err).Msgf("DeleteExcessRows() - Failed to delete excess rows in %s", tableName) 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /core/stats-motion-helpers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | func getHighestAircraftFloor(pg *postgres) int { 8 | 9 | var returnValue int 10 | defaultValue := 0 11 | 12 | query := `SELECT barometric_altitude 13 | FROM highest_aircraft 14 | ORDER BY barometric_altitude ASC, first_seen ASC 15 | LIMIT 1` 16 | 17 | err := pg.db.QueryRow(context.Background(), query).Scan(&returnValue) 18 | if err == nil { 19 | return returnValue 20 | } else { 21 | return defaultValue 22 | } 23 | } 24 | 25 | func getLowestAircraftCeiling(pg *postgres) int { 26 | 27 | var returnValue int 28 | defaultValue := 999999 29 | 30 | query := `SELECT barometric_altitude 31 | FROM lowest_aircraft 32 | ORDER BY barometric_altitude DESC, first_seen ASC 33 | LIMIT 1` 34 | 35 | err := pg.db.QueryRow(context.Background(), query).Scan(&returnValue) 36 | if err == nil { 37 | return returnValue 38 | } else { 39 | return defaultValue 40 | } 41 | } 42 | 43 | func getFastestAircraftFloor(pg *postgres) float64 { 44 | 45 | var returnValue float64 46 | defaultValue := 0.0 47 | 48 | query := `SELECT ground_speed 49 | FROM fastest_aircraft 50 | ORDER BY ground_speed ASC, first_seen ASC 51 | LIMIT 1` 52 | 53 | err := pg.db.QueryRow(context.Background(), query).Scan(&returnValue) 54 | if err == nil { 55 | return returnValue 56 | } else { 57 | return defaultValue 58 | } 59 | } 60 | 61 | func getSlowestAircraftCeiling(pg *postgres) float64 { 62 | 63 | var returnValue float64 64 | defaultValue := 99999.0 65 | 66 | query := `SELECT ground_speed 67 | FROM slowest_aircraft 68 | ORDER BY ground_speed DESC, first_seen ASC 69 | LIMIT 1` 70 | 71 | err := pg.db.QueryRow(context.Background(), query).Scan(&returnValue) 72 | if err == nil { 73 | return returnValue 74 | } else { 75 | return defaultValue 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /rootfs/etc/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | user www-data; 2 | worker_processes 1; 3 | pid /run/nginx.pid; 4 | include /etc/nginx/modules-enabled/*.conf; 5 | daemon off; 6 | 7 | events { 8 | # worker_connections 768; 9 | # multi_accept on; 10 | } 11 | 12 | http { 13 | 14 | ## 15 | # Basic Settings 16 | ## 17 | 18 | sendfile on; 19 | tcp_nopush on; 20 | tcp_nodelay on; 21 | keepalive_timeout 65; 22 | types_hash_max_size 2048; 23 | server_tokens off; 24 | 25 | # server_names_hash_bucket_size 64; 26 | # server_name_in_redirect off; 27 | 28 | include /etc/nginx/mime.types; 29 | default_type application/octet-stream; 30 | 31 | ## 32 | # Logging Settings 33 | ## 34 | 35 | #access_log /dev/stdout; 36 | access_log off; 37 | error_log /dev/stdout notice; 38 | 39 | ## 40 | # Gzip Settings 41 | ## 42 | 43 | gzip on; 44 | 45 | # gzip_vary on; 46 | # gzip_proxied any; 47 | # gzip_comp_level 6; 48 | # gzip_buffers 16 8k; 49 | # gzip_http_version 1.1; 50 | gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; 51 | 52 | 53 | server { 54 | listen 80 default_server; 55 | root /var/www/html; 56 | server_name _; 57 | 58 | location /api/ { 59 | gzip on; 60 | proxy_http_version 1.1; 61 | proxy_read_timeout 15s; 62 | proxy_connect_timeout 1s; 63 | proxy_max_temp_file_size 0; 64 | proxy_set_header Upgrade $http_upgrade; 65 | proxy_set_header Connection $http_connection; 66 | proxy_set_header Host $http_host; 67 | proxy_pass http://127.0.0.1:8080/api/; 68 | } 69 | 70 | location / { 71 | alias /app/dist/; 72 | try_files $uri $uri/ =404; 73 | absolute_redirect off; 74 | gzip on; 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /data/airlines_embed.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "bytes" 5 | _ "embed" 6 | "encoding/csv" 7 | "strings" 8 | "sync" 9 | ) 10 | 11 | //go:embed airlines.csv 12 | var airlinesCSV []byte 13 | 14 | type Airline struct { 15 | Code string 16 | Name string 17 | ICAO string 18 | IATA string 19 | } 20 | 21 | var ( 22 | airlinesOnce sync.Once 23 | airlinesIndex map[string]Airline 24 | ) 25 | 26 | func loadAirlines() { 27 | airlinesIndex = make(map[string]Airline) 28 | reader := csv.NewReader(bytes.NewReader(airlinesCSV)) 29 | 30 | records, err := reader.ReadAll() 31 | if err != nil || len(records) < 2 { 32 | return 33 | } 34 | 35 | records[0][0] = strings.TrimPrefix(records[0][0], "\ufeff") 36 | 37 | cols := make(map[string]int) 38 | for i, name := range records[0] { 39 | cols[strings.ToLower(strings.TrimSpace(name))] = i 40 | } 41 | 42 | for _, row := range records[1:] { 43 | airline := Airline{ 44 | Code: getValue(row, cols["code"]), 45 | Name: getValue(row, cols["name"]), 46 | ICAO: getValue(row, cols["icao"]), 47 | IATA: getValue(row, cols["iata"]), 48 | } 49 | 50 | if airline.Name == "" { 51 | continue 52 | } 53 | 54 | for _, key := range []string{airline.Code, airline.ICAO, airline.IATA} { 55 | if key = strings.ToUpper(strings.TrimSpace(key)); key != "" { 56 | if _, exists := airlinesIndex[key]; !exists { 57 | airlinesIndex[key] = airline 58 | } 59 | } 60 | } 61 | } 62 | } 63 | 64 | func getValue(row []string, idx int) string { 65 | if idx >= 0 && idx < len(row) { 66 | return strings.TrimSpace(row[idx]) 67 | } 68 | return "" 69 | } 70 | 71 | func LookupAirline(code string) (Airline, bool) { 72 | code = strings.ToUpper(strings.TrimSpace(code)) 73 | if code == "" { 74 | return Airline{}, false 75 | } 76 | 77 | airlinesOnce.Do(loadAirlines) 78 | airline, ok := airlinesIndex[code] 79 | return airline, ok 80 | } 81 | -------------------------------------------------------------------------------- /web/src/assets/svelte.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/components/ActivityTopTypesByPeriod.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 |
9 |
10 |
11 |

{title}

12 |
13 | 22 | 30 | 38 | 46 |
47 |
48 | {#if selectedTab === 0} 49 | 50 | {:else if selectedTab === 1} 51 | 52 | {:else if selectedTab === 2} 53 | 54 | {:else if selectedTab === 3} 55 | 56 | {/if} 57 |
58 |
59 | 60 | 61 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tomcarman/skystats 2 | 3 | go 1.25.3 4 | 5 | require github.com/JamesLMilner/cheap-ruler-go v0.0.0-20191212211616-0919b75413a9 6 | 7 | require ( 8 | github.com/gin-gonic/gin v1.10.1 9 | github.com/golang-migrate/migrate/v4 v4.19.0 10 | github.com/jackc/pgx/v5 v5.7.5 11 | github.com/lib/pq v1.10.9 12 | github.com/rs/zerolog v1.34.0 13 | github.com/sevlyar/go-daemon v0.1.6 14 | ) 15 | 16 | require ( 17 | github.com/bytedance/sonic v1.14.0 // indirect 18 | github.com/bytedance/sonic/loader v0.3.0 // indirect 19 | github.com/cloudwego/base64x v0.1.6 // indirect 20 | github.com/gabriel-vasile/mimetype v1.4.9 // indirect 21 | github.com/gin-contrib/sse v1.1.0 // indirect 22 | github.com/go-playground/locales v0.14.1 // indirect 23 | github.com/go-playground/universal-translator v0.18.1 // indirect 24 | github.com/go-playground/validator/v10 v10.27.0 // indirect 25 | github.com/goccy/go-json v0.10.5 // indirect 26 | github.com/hashicorp/errwrap v1.1.0 // indirect 27 | github.com/hashicorp/go-multierror v1.1.1 // indirect 28 | github.com/json-iterator/go v1.1.12 // indirect 29 | github.com/klauspost/cpuid/v2 v2.3.0 // indirect 30 | github.com/kr/text v0.2.0 // indirect 31 | github.com/leodido/go-urn v1.4.0 // indirect 32 | github.com/mattn/go-colorable v0.1.13 // indirect 33 | github.com/mattn/go-isatty v0.0.20 // indirect 34 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 35 | github.com/modern-go/reflect2 v1.0.2 // indirect 36 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 37 | github.com/rogpeppe/go-internal v1.14.1 // indirect 38 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 39 | github.com/ugorji/go/codec v1.3.0 // indirect 40 | golang.org/x/arch v0.20.0 // indirect 41 | golang.org/x/net v0.43.0 // indirect 42 | google.golang.org/protobuf v1.36.7 // indirect 43 | gopkg.in/yaml.v3 v3.0.1 // indirect 44 | ) 45 | 46 | require ( 47 | github.com/jackc/pgpassfile v1.0.0 // indirect 48 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 49 | github.com/jackc/puddle/v2 v2.2.2 // indirect 50 | github.com/joho/godotenv v1.5.1 51 | github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 // indirect 52 | golang.org/x/crypto v0.41.0 // indirect 53 | golang.org/x/sync v0.16.0 // indirect 54 | golang.org/x/sys v0.37.0 // indirect 55 | golang.org/x/text v0.28.0 // indirect 56 | ) 57 | -------------------------------------------------------------------------------- /web/src/stores/settings.js: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | 3 | const { subscribe, set, update } = writable({}); 4 | 5 | // arbitrary counter that gets incremented on settings save to 6 | // trigger refresh (there might be a better way...) 7 | export const refreshRouteData = writable(0); 8 | export const refreshInterestingData = writable(0); 9 | export const refreshRecordHolderData = writable(0); 10 | 11 | export const settings = { 12 | subscribe, 13 | 14 | async load() { 15 | try { 16 | const response = await fetch('/api/settings'); 17 | if (response.ok) { 18 | const data = await response.json(); 19 | const settingsObj = {}; 20 | data.forEach(setting => { 21 | settingsObj[setting.setting_key] = setting; 22 | }); 23 | set(settingsObj); 24 | return settingsObj; 25 | } 26 | } catch (error) { 27 | console.error('Failed to load settings:', error); 28 | } 29 | }, 30 | 31 | async save(updates) { 32 | try { 33 | const response = await fetch('/api/settings', { 34 | method: 'PUT', 35 | headers: { 36 | 'Content-Type': 'application/json', 37 | }, 38 | body: JSON.stringify(updates), 39 | }); 40 | 41 | if (response.ok) { 42 | const data = await response.json(); 43 | const settingsObj = {}; 44 | data.forEach(setting => { 45 | settingsObj[setting.setting_key] = setting; 46 | }); 47 | set(settingsObj); 48 | 49 | // increment counter to trigger refresh 50 | if (updates.route_table_limit !== undefined) { 51 | refreshRouteData.update(n => n + 1); 52 | } 53 | if (updates.interesting_table_limit !== undefined) { 54 | refreshInterestingData.update(n => n + 1); 55 | } 56 | if (updates.record_holder_table_limit !== undefined) { 57 | refreshRecordHolderData.update(n => n + 1); 58 | } 59 | 60 | return true; 61 | } 62 | return false; 63 | } catch (error) { 64 | console.error('Failed to save settings:', error); 65 | return false; 66 | } 67 | }, 68 | 69 | // get setting 70 | getValue(key) { 71 | let value = null; 72 | const unsubscribe = subscribe(s => { 73 | value = s[key]?.setting_value; 74 | }); 75 | unsubscribe(); 76 | return value; 77 | } 78 | }; 79 | 80 | settings.load(); -------------------------------------------------------------------------------- /scripts/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # DISCLAIMER - this script was mostly written by *AI* because it's only for 3 | # my local dev use...but it does the job. 4 | 5 | # Colors for output 6 | RED='\033[0;31m' 7 | GREEN='\033[0;32m' 8 | YELLOW='\033[1;33m' 9 | NC='\033[0m' # No Color 10 | 11 | # Configuration 12 | BUILD_DIR="$HOME/dev/skystats/build" 13 | PID_FILE="${BUILD_DIR}/skystats.pid" 14 | LOG_FILE="${BUILD_DIR}/skystats.log" 15 | BINARY_NAME="skystats-daemon" 16 | 17 | echo -e "${GREEN}Starting development build process...${NC}" 18 | 19 | # Create build directory if it doesn't exist 20 | if [ ! -d "$BUILD_DIR" ]; then 21 | echo -e "${YELLOW}Creating build directory...${NC}" 22 | mkdir -p "$BUILD_DIR" 23 | fi 24 | 25 | # Kill existing daemon if running 26 | if [ -f "$PID_FILE" ]; then 27 | PID=$(cat "$PID_FILE" 2>/dev/null) 28 | if [ ! -z "$PID" ] && kill -0 "$PID" 2>/dev/null; then 29 | echo -e "${YELLOW}Killing existing daemon (PID: $PID)...${NC}" 30 | kill "$PID" 31 | # Give it a moment to shut down gracefully 32 | sleep 1 33 | # Force kill if still running 34 | if kill -0 "$PID" 2>/dev/null; then 35 | echo -e "${YELLOW}Force killing daemon...${NC}" 36 | kill -9 "$PID" 2>/dev/null 37 | fi 38 | else 39 | echo -e "${YELLOW}PID file exists but process not running${NC}" 40 | fi 41 | else 42 | echo -e "${YELLOW}No existing daemon found${NC}" 43 | fi 44 | 45 | # Remove existing log file 46 | if [ -f "$LOG_FILE" ]; then 47 | echo -e "${YELLOW}Removing existing log file...${NC}" 48 | rm "$LOG_FILE" 49 | fi 50 | 51 | # Get build information 52 | VERSION="dev" 53 | BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") 54 | SHORT_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") 55 | COMMIT="${BRANCH}-${SHORT_SHA}" 56 | DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') 57 | 58 | echo -e "${GREEN}Building with:${NC}" 59 | echo " Version: ${VERSION}" 60 | echo " Commit: ${COMMIT}" 61 | echo " Date: ${DATE}" 62 | 63 | # Build the daemon with ldflags 64 | echo -e "${GREEN}Building ${BINARY_NAME}...${NC}" 65 | cd $HOME/dev/skystats && go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" -o "${BUILD_DIR}/${BINARY_NAME}" ./core 66 | 67 | if [ $? -ne 0 ]; then 68 | echo -e "${RED}Build failed!${NC}" 69 | return 1 2>/dev/null || exit 1 70 | fi 71 | 72 | echo -e "${GREEN}Build successful!${NC}" 73 | 74 | # Run the daemon from build directory (it will daemonize itself) 75 | echo -e "${GREEN}Starting ${BINARY_NAME}...${NC}" 76 | cd "${BUILD_DIR}" && ./"${BINARY_NAME}" 77 | 78 | if [ $? -eq 0 ]; then 79 | echo -e "${GREEN}Daemon started successfully${NC}" 80 | else 81 | echo -e "${RED}Failed to start daemon${NC}" 82 | return 1 2>/dev/null || exit 1 83 | fi 84 | -------------------------------------------------------------------------------- /web/src/components/RouteTopAirportsDomestic.svelte: -------------------------------------------------------------------------------- 1 | 45 | 46 | {#if loading} 47 | 48 | {:else if error} 49 |
50 | 51 | Something went wrong: {error} 52 |
53 | {:else if data.length === 0} 54 |
55 | 56 | No data available 57 |
58 | {:else} 59 |
    60 |
  • Top Domestic Airports
  • 61 | 62 | {#each data as airport, index} 63 |
  • 64 |
    {airport.airport_code}
    65 |
    66 |
    {airport.airport_name}
    67 |
    {airport.airport_country}
    68 |
    69 |
    70 |
    {airport.flight_count.toLocaleString()}
    71 |
    flights
    72 |
    73 |
  • 74 | {/each} 75 |
76 | {/if} 77 | -------------------------------------------------------------------------------- /web/src/components/RouteTopAirportsInternational.svelte: -------------------------------------------------------------------------------- 1 | 45 | 46 | {#if loading} 47 | 48 | {:else if error} 49 |
50 | 51 | Something went wrong: {error} 52 |
53 | {:else if data.length === 0} 54 |
55 | 56 | No data available 57 |
58 | {:else} 59 |
    60 |
  • Top International Airports
  • 61 | 62 | {#each data as airport, index} 63 |
  • 64 |
    {airport.airport_code}
    65 |
    66 |
    {airport.airport_name}
    67 |
    {airport.airport_country}
    68 |
    69 |
    70 |
    {airport.flight_count.toLocaleString()}
    71 |
    flights
    72 |
    73 |
  • 74 | {/each} 75 |
76 | {/if} 77 | -------------------------------------------------------------------------------- /web/src/components/RouteTopCountriesDestination.svelte: -------------------------------------------------------------------------------- 1 | 45 | 46 | {#if loading} 47 | 48 | {:else if error} 49 |
50 | 51 | Something went wrong: {error} 52 |
53 | {:else if data.length === 0} 54 |
55 | 56 | No data available 57 |
58 | {:else} 59 |
    60 |
  • Top Destination Countries
  • 61 | 62 | 63 | {#each data as topCountry, index} 64 |
  • 65 |
    66 |
    67 |
    {topCountry.country_name}
    68 |
    {topCountry.country_iso}
    69 |
    70 |
    71 |
    {topCountry.flight_count.toLocaleString()}
    72 |
    flights
    73 |
    74 |
  • 75 | {/each} 76 |
77 | {/if} 78 | -------------------------------------------------------------------------------- /web/src/components/RouteTopCountriesOrigin.svelte: -------------------------------------------------------------------------------- 1 | 47 | 48 | {#if loading} 49 | 50 | {:else if error} 51 |
52 | 53 | Something went wrong: {error} 54 |
55 | {:else if data.length === 0} 56 |
57 | 58 | No data available 59 |
60 | {:else} 61 |
    62 |
  • Top Origin Countries
  • 63 | 64 | 65 | {#each data as topCountry, index} 66 |
  • 67 |
    68 |
    69 |
    {topCountry.country_name}
    70 |
    {topCountry.country_iso}
    71 |
    72 |
    73 |
    {topCountry.flight_count.toLocaleString()}
    74 |
    flights
    75 |
    76 |
  • 77 | {/each} 78 |
79 | {/if} 80 | -------------------------------------------------------------------------------- /web/src/components/RouteTopAirlines.svelte: -------------------------------------------------------------------------------- 1 | 45 | 46 | {#if loading} 47 | 48 | {:else if error} 49 |
50 | 51 | Something went wrong: {error} 52 |
53 | {:else if data.length === 0} 54 |
55 | 56 | No data available 57 |
58 | {:else} 59 |
    60 |
  • Top Airlines
  • 61 | 62 | 63 | {#each data as airline, index} 64 |
  • 65 |
    66 | {airline.airline_icao} 67 |
    68 |
    69 |
    {airline.airline_name}
    70 |
    {airline.airline_icao} / {airline.airline_iata}
    71 |
    72 |
    73 |
    {airline.flight_count.toLocaleString()}
    74 |
    flights
    75 |
    76 |
  • 77 | {/each} 78 |
79 | {/if} 80 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # Svelte + Vite 2 | 3 | This template should help get you started developing with Svelte in Vite. 4 | 5 | ## Recommended IDE Setup 6 | 7 | [VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode). 8 | 9 | ## Need an official Svelte framework? 10 | 11 | Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more. 12 | 13 | ## Technical considerations 14 | 15 | **Why use this over SvelteKit?** 16 | 17 | - It brings its own routing solution which might not be preferable for some users. 18 | - It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app. 19 | 20 | This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project. 21 | 22 | Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate. 23 | 24 | **Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?** 25 | 26 | Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information. 27 | 28 | **Why include `.vscode/extensions.json`?** 29 | 30 | Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project. 31 | 32 | **Why enable `checkJs` in the JS template?** 33 | 34 | It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration. 35 | 36 | **Why is HMR not preserving my local component state?** 37 | 38 | HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state). 39 | 40 | If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR. 41 | 42 | ```js 43 | // store.js 44 | // An extremely simple external store 45 | import { writable } from 'svelte/store' 46 | export default writable(0) 47 | ``` 48 | -------------------------------------------------------------------------------- /web/src/components/RouteTopRoutes.svelte: -------------------------------------------------------------------------------- 1 | 47 | 48 | {#if loading} 49 | 50 | {:else if error} 51 |
52 | 53 | Something went wrong: {error} 54 |
55 | {:else if data.length === 0} 56 |
57 | 58 | No data available 59 |
60 | {:else} 61 |
    62 |
  • Top Routes
  • 63 | {#each data as route, index} 64 |
  • 65 |
    66 |
    {route.origin_iata_code}
    67 | 68 |
    {route.destination_iata_code}
    69 |
    70 |
    71 |
    {route.origin_name}
    72 |
    {route.destination_name}
    73 |
    74 |
    75 |
    {route.flight_count.toLocaleString()}
    76 |
    flights
    77 |
    78 |
  • 79 | {/each} 80 |
81 | {/if} 82 | -------------------------------------------------------------------------------- /core/settings.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/rs/zerolog/log" 8 | ) 9 | 10 | type UserSetting struct { 11 | ID int `json:"id"` 12 | SettingKey string `json:"setting_key"` 13 | SettingValue string `json:"setting_value"` 14 | Description string `json:"description"` 15 | } 16 | 17 | type SettingsService struct { 18 | pg *postgres 19 | } 20 | 21 | func NewSettingsService(pg *postgres) *SettingsService { 22 | return &SettingsService{pg: pg} 23 | } 24 | 25 | func (s *SettingsService) GetAllSettings() ([]UserSetting, error) { 26 | query := ` 27 | SELECT id, setting_key, setting_value, description 28 | FROM user_settings 29 | ORDER BY setting_key` 30 | 31 | rows, err := s.pg.db.Query(context.Background(), query) 32 | if err != nil { 33 | log.Error().Err(err).Msg("Failed to retrieve user settings") 34 | return nil, fmt.Errorf("Failed to retrieve user settings: %w", err) 35 | } 36 | defer rows.Close() 37 | 38 | var settings []UserSetting 39 | for rows.Next() { 40 | var setting UserSetting 41 | err := rows.Scan(&setting.ID, &setting.SettingKey, &setting.SettingValue, &setting.Description) 42 | if err != nil { 43 | log.Error().Err(err).Msg("Failed to read user settings") 44 | return nil, fmt.Errorf("Failed to read user settings: %w", err) 45 | } 46 | settings = append(settings, setting) 47 | } 48 | 49 | return settings, nil 50 | } 51 | 52 | func (s *SettingsService) GetSetting(key string) (*UserSetting, error) { 53 | query := ` 54 | SELECT id, setting_key, setting_value, description 55 | FROM user_settings 56 | WHERE setting_key = $1` 57 | 58 | var setting UserSetting 59 | err := s.pg.db.QueryRow(context.Background(), query, key).Scan( 60 | &setting.ID, &setting.SettingKey, &setting.SettingValue, &setting.Description) 61 | 62 | if err != nil { 63 | log.Error().Err(err).Msgf("Failed to retrieve user setting %s", key) 64 | return nil, fmt.Errorf("Failed to retrieve user setting %s: %w", key, err) 65 | } 66 | 67 | return &setting, nil 68 | } 69 | 70 | func (s *SettingsService) UpdateSetting(key string, value string) error { 71 | query := ` 72 | UPDATE user_settings 73 | SET setting_value = $2 74 | WHERE setting_key = $1` 75 | 76 | result, err := s.pg.db.Exec(context.Background(), query, key, value) 77 | if err != nil { 78 | log.Error().Err(err).Msgf("Failed to update setting %s", key) 79 | return fmt.Errorf("Failed to update setting %s: %w", key, err) 80 | } 81 | 82 | if result.RowsAffected() == 1 { 83 | log.Debug().Msgf("%s updated to %s", key, value) 84 | } 85 | 86 | return nil 87 | } 88 | 89 | func (s *SettingsService) UpdateSettings(settings map[string]string) error { 90 | 91 | tx, _ := s.pg.db.Begin(context.Background()) 92 | defer tx.Rollback(context.Background()) 93 | 94 | query := ` 95 | UPDATE user_settings 96 | SET setting_value = $2 97 | WHERE setting_key = $1` 98 | 99 | for key, value := range settings { 100 | _, err := tx.Exec(context.Background(), query, key, value) 101 | if err != nil { 102 | log.Error().Err(err).Msgf("Failed to update setting %s", key) 103 | return fmt.Errorf("Failed to update setting %s: %w", key, err) 104 | } 105 | } 106 | 107 | if err := tx.Commit(context.Background()); err != nil { 108 | log.Error().Err(err).Msg("Failed to commit settings") 109 | return fmt.Errorf("Failed to commit settings: %w", err) 110 | } 111 | 112 | return nil 113 | } 114 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: docker build 2 | 3 | on: 4 | schedule: 5 | - cron: "0 5 * * 1" 6 | push: 7 | branches: 8 | - "main" 9 | - "test" 10 | tags: 11 | - "v*.*.*" 12 | pull_request: 13 | branches: 14 | - "main" 15 | - "dev" 16 | - "test" 17 | 18 | jobs: 19 | docker: 20 | runs-on: ubuntu-latest 21 | permissions: 22 | packages: write 23 | steps: 24 | - name: Set variables useful for later 25 | id: useful_vars 26 | run: |- 27 | echo "timestamp=$(date +%s)" >> $GITHUB_OUTPUT 28 | echo "short_sha=${GITHUB_SHA::8}" >> $GITHUB_OUTPUT 29 | echo "iso_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT 30 | - name: Checkout 31 | uses: actions/checkout@v3 32 | - name: Docker meta 33 | id: docker_meta 34 | uses: docker/metadata-action@v4 35 | with: 36 | images: ghcr.io/${{ github.repository }} 37 | tags: | 38 | type=schedule 39 | type=ref,event=branch 40 | type=ref,event=pr 41 | type=semver,pattern={{version}} 42 | type=semver,pattern={{major}}.{{minor}} 43 | type=semver,pattern={{major}} 44 | type=sha,prefix=,format=long,event=tag 45 | type=sha 46 | type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} 47 | type=raw,value=${{ github.ref_name }}-${{ steps.useful_vars.outputs.short_sha }}-${{ steps.useful_vars.outputs.timestamp }},enable=${{ endsWith(github.ref, github.event.repository.default_branch) }} 48 | - name: Set up QEMU 49 | uses: docker/setup-qemu-action@v2 50 | - name: Set up Docker Buildx 51 | uses: docker/setup-buildx-action@v2 52 | - name: Login to GHCR 53 | if: github.event_name != 'pull_request' 54 | uses: docker/login-action@v2 55 | with: 56 | registry: ghcr.io 57 | username: ${{ github.repository_owner }} 58 | password: ${{ secrets.GITHUB_TOKEN }} 59 | - name: Build and push 60 | uses: docker/build-push-action@v4 61 | with: 62 | context: . 63 | push: ${{ github.event_name != 'pull_request' }} 64 | tags: ${{ steps.docker_meta.outputs.tags }} 65 | labels: ${{ steps.docker_meta.outputs.labels }} 66 | platforms: linux/amd64,linux/arm64 67 | build-args: | 68 | BUILDKIT_CONTEXT_KEEP_GIT_DIR=true 69 | VERSION=${{ 70 | startsWith(github.ref, 'refs/tags/v') && github.ref_name || 71 | (github.event_name == 'pull_request' && format('pr-{0}-{1}', github.event.pull_request.number, steps.useful_vars.outputs.short_sha)) || 72 | format('{0}-{1}', github.ref_name, steps.useful_vars.outputs.short_sha) 73 | }} 74 | COMMIT=${{ steps.useful_vars.outputs.short_sha }} 75 | DATE=${{ steps.useful_vars.outputs.iso_date }} 76 | release: 77 | needs: docker 78 | if: startsWith(github.ref, 'refs/tags/v') 79 | permissions: 80 | contents: write 81 | runs-on: ubuntu-latest 82 | steps: 83 | - name: Checkout 84 | uses: actions/checkout@v5 85 | with: 86 | fetch-depth: 0 87 | - name: Set up Go 88 | uses: actions/setup-go@v5 89 | with: 90 | go-version-file: 'go.mod' 91 | - name: Run GoReleaser 92 | uses: goreleaser/goreleaser-action@v6 93 | with: 94 | distribution: goreleaser 95 | version: latest 96 | args: release --clean 97 | env: 98 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /core/db-migrations.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | 7 | "github.com/rs/zerolog/log" 8 | 9 | "github.com/golang-migrate/migrate/v4" 10 | postgres_migrate "github.com/golang-migrate/migrate/v4/database/postgres" 11 | _ "github.com/golang-migrate/migrate/v4/source/file" 12 | _ "github.com/lib/pq" 13 | ) 14 | 15 | func RunDatabaseMigrations() error { 16 | 17 | // Setup db connection 18 | url := GetConnectionUrl() + "?sslmode=disable" 19 | db, err := sql.Open("postgres", url) 20 | if err != nil { 21 | return fmt.Errorf("Error connecting to the database: %w", err) 22 | } 23 | defer db.Close() 24 | 25 | // Check for existing pre-script db 26 | isExistingDb, err := checkForExistingDatabase(db) 27 | if err != nil { 28 | return fmt.Errorf("Error checking if its a pre-script version of the db: %w", err) 29 | } 30 | 31 | // Setup migrator 32 | migrator, err := initMigrator(db) 33 | if err != nil { 34 | return fmt.Errorf("Error initialising database migrator: %w", err) 35 | } 36 | 37 | // If it was a pre-script DB, force version to 1 38 | if isExistingDb { 39 | log.Info().Msg("Found existing pre-scripted db. Automatically setting as version 1...") 40 | if err := migrator.Force(1); err != nil { 41 | return fmt.Errorf("Error forcing migration version to 1 in pre-scripted db: %w", err) 42 | } 43 | log.Info().Msg("Successfully marked existing database as migration version 1") 44 | } 45 | 46 | // Run migrations 47 | err = migrator.Up() 48 | if err != nil && err != migrate.ErrNoChange { 49 | return fmt.Errorf("Database migration failed: %w", err) 50 | } 51 | 52 | if err == migrate.ErrNoChange { 53 | log.Info().Msg("Database schema is up to date, no migrations needed") 54 | } else { 55 | version, _, err := migrator.Version() 56 | if err != nil { 57 | log.Warn().Msgf("Migration completed, but unable to get current version: %v", err) 58 | } 59 | log.Info().Msgf("Successfully migrated database to version: %d", version) 60 | } 61 | 62 | return nil 63 | } 64 | 65 | func initMigrator(db *sql.DB) (*migrate.Migrate, error) { 66 | driver, err := postgres_migrate.WithInstance(db, &postgres_migrate.Config{}) 67 | if err != nil { 68 | return nil, fmt.Errorf("Error creating migration driver: %w", err) 69 | } 70 | 71 | migrator, err := migrate.NewWithDatabaseInstance( 72 | "file://../migrations", 73 | "postgres", 74 | driver, 75 | ) 76 | if err != nil { 77 | return nil, fmt.Errorf("Error creating migrator instance: %w", err) 78 | } 79 | 80 | return migrator, nil 81 | } 82 | 83 | // Only for users who were using a version of Skystats prior to the db 84 | // creation being scripted. Checks for when the schema_migrations table does not exist, 85 | // but the aircraft_data does. If so, forces the db version to 1. 86 | func checkForExistingDatabase(db *sql.DB) (bool, error) { 87 | 88 | var migrationTableExists bool 89 | err := db.QueryRow(` 90 | SELECT EXISTS ( 91 | SELECT FROM information_schema.tables 92 | WHERE table_schema = 'public' 93 | AND table_name = 'schema_migrations' 94 | ) 95 | `).Scan(&migrationTableExists) 96 | 97 | if err != nil { 98 | return false, err 99 | } 100 | 101 | if migrationTableExists { 102 | return false, nil 103 | } 104 | 105 | var aircraftTableExists bool 106 | err = db.QueryRow(` 107 | SELECT EXISTS ( 108 | SELECT FROM information_schema.tables 109 | WHERE table_schema = 'public' 110 | AND table_name = 'aircraft_data' 111 | ) 112 | `).Scan(&aircraftTableExists) 113 | 114 | if err != nil { 115 | return false, err 116 | } 117 | 118 | return aircraftTableExists, nil 119 | } 120 | -------------------------------------------------------------------------------- /web/src/components/MetricRoutes.svelte: -------------------------------------------------------------------------------- 1 | 41 | {#if loading} 42 |
43 | 44 |
45 | {:else if error} 46 |
47 | 48 | Something went wrong: {error} 49 |
50 | {:else if data.length === 0} 51 |
52 | 53 | No data available 54 |
55 | {:else} 56 |
57 |
58 |
59 |
60 | 61 |
62 |
63 |
Flights with routes
64 |
65 |
all time
66 |
67 |
68 |
69 |
70 | 71 |
72 |
73 |
Unique Countries
74 |
75 |
all time
76 |
77 |
78 |
79 |
80 | 81 |
82 |
83 |
Unique Airports
84 |
85 |
all time
86 |
87 |
88 | {/if} 89 | -------------------------------------------------------------------------------- /web/src/components/MetricFlightsSeen.svelte: -------------------------------------------------------------------------------- 1 | 42 | {#if loading} 43 | 44 | {:else if error} 45 |
46 | 47 | Something went wrong: {error} 48 |
49 | {:else if data.length === 0} 50 |
51 | 52 | No data available 53 |
54 | {:else} 55 |
56 |
57 |
58 |
59 | 60 |
61 |
62 |
Flights seen
63 |
64 |
past hour
65 |
66 |
67 |
68 |
69 | 70 |
71 |
72 |
Flights seen
73 |
74 |
today
75 |
76 |
77 |
78 |
79 | 80 |
81 |
82 |
Flights seen
83 |
84 |
all time
85 |
86 |
87 | {/if} 88 | -------------------------------------------------------------------------------- /web/src/components/MetricAircraftSeen.svelte: -------------------------------------------------------------------------------- 1 | 43 | {#if loading} 44 | 45 | {:else if error} 46 |
47 | 48 | Something went wrong: {error} 49 |
50 | {:else if data.length === 0} 51 |
52 | 53 | No data available 54 |
55 | {:else} 56 |
57 |
58 |
59 |
60 | 61 |
62 |
63 |
Aircraft seen
64 |
65 |
past hour
66 |
67 |
68 |
69 |
70 | 71 |
72 |
73 |
Aircraft seen
74 |
75 |
today
76 |
77 |
78 |
79 |
80 | 81 |
82 |
83 |
Aircraft seen
84 |
85 |
all time
86 |
87 |
88 | {/if} 89 | -------------------------------------------------------------------------------- /web/src/components/MetricInteresting.svelte: -------------------------------------------------------------------------------- 1 | 42 | {#if loading} 43 |
44 | 45 |
46 | {:else if error} 47 |
48 | 49 | Something went wrong: {error} 50 |
51 | {:else if data.length === 0} 52 |
53 | 54 | No data available 55 |
56 | {:else} 57 |
58 |
59 |
60 |
61 | 62 |
63 |
64 |
Interesting flights seen
65 |
66 |
past hour
67 |
68 |
69 |
70 |
71 | 72 |
73 |
74 |
Interesting flights seen
75 |
76 |
today
77 |
78 |
79 |
80 |
81 | 82 |
83 |
84 |
Interesting flights seen
85 |
86 |
all time
87 |
88 |
89 | {/if} 90 | -------------------------------------------------------------------------------- /web/src/components/MotionStats.svelte: -------------------------------------------------------------------------------- 1 | 50 | 51 |
52 |
53 |
54 |
55 | {#if loading} 56 |
57 | 58 |
59 | {:else if error} 60 |
61 | 62 | Something went wrong: {error} 63 |
64 | {:else if data.length === 0} 65 |
66 | 67 | No data available 68 |
69 | {:else} 70 | 71 |
72 | {#if icon} 73 |
74 | 75 |
76 | {/if} 77 |

{title}

78 |
79 | 80 | 81 | 82 | 83 | {#each columns as column} 84 | 85 | {/each} 86 | 87 | 88 | 89 | {#each data as aircraft} 90 | 91 | {#each columns as column} 92 | 99 | {/each} 100 | 101 | {/each} 102 | 103 |
{column.header}
93 | {#if column.formatter} 94 | {@html column.formatter(aircraft[column.field])} 95 | {:else} 96 | {aircraft[column.field] || '-'} 97 | {/if} 98 |
104 | {/if} 105 |
106 |
107 |
108 |
-------------------------------------------------------------------------------- /web/src/App.svelte: -------------------------------------------------------------------------------- 1 | 36 | 37 | 38 | 78 | 79 |
80 |
81 | 82 |
83 | 84 | 85 |
86 | {#each tabs as tab} 87 | 95 | {/each} 96 |
97 | 98 | 99 |
100 | {#each tabs as tab} 101 |
102 | 103 |
104 | {/each} 105 |
106 | 107 |
108 |
109 | 110 | 111 | 112 | 129 | -------------------------------------------------------------------------------- /core/core.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | 10 | "github.com/joho/godotenv" 11 | "github.com/rs/zerolog" 12 | "github.com/rs/zerolog/log" 13 | "github.com/sevlyar/go-daemon" 14 | ) 15 | 16 | func main() { 17 | 18 | checkFlags() 19 | 20 | // Initialize logger 21 | consoleWriter := zerolog.ConsoleWriter{ 22 | Out: os.Stderr, 23 | TimeFormat: "2006-01-02 15:04:05", 24 | TimeLocation: time.Local, 25 | } 26 | log.Logger = log.Output(consoleWriter) 27 | 28 | // Load .env file 29 | if err := godotenv.Load("../.env"); err != nil { 30 | if err := godotenv.Load(); err != nil { 31 | log.Info().Msg("No .env file found, using environment variables") 32 | } 33 | } 34 | 35 | // Set log level 36 | if os.Getenv("DOCKER_ENV") == "true" { 37 | setLogLevel() 38 | } 39 | 40 | // If running outside of docker, run as a daemon 41 | if os.Getenv("DOCKER_ENV") != "true" { 42 | 43 | execPath, _ := os.Executable() 44 | execDir := filepath.Dir(execPath) 45 | 46 | cntxt := &daemon.Context{ 47 | PidFileName: filepath.Join(execDir, "skystats.pid"), 48 | PidFilePerm: 0644, 49 | LogFileName: filepath.Join(execDir, "skystats.log"), 50 | LogFilePerm: 0640, 51 | WorkDir: execDir, 52 | Umask: 027, 53 | } 54 | 55 | d, err := cntxt.Reborn() 56 | 57 | if err != nil { 58 | log.Fatal().Err(err).Msg("Failed to launch daemon") 59 | } 60 | if d != nil { 61 | return 62 | } 63 | defer cntxt.Release() 64 | 65 | // when running as daemon, logs are written to file, disable ansi colors + set log level 66 | consoleWriter.NoColor = true 67 | log.Logger = log.Output(consoleWriter) 68 | setLogLevel() 69 | 70 | log.Info().Msg("Skystats: Running in daemon mode") 71 | } 72 | 73 | url := GetConnectionUrl() 74 | 75 | log.Info().Msg("Connecting to Postgres database") 76 | 77 | pg, err := NewPG(context.Background(), url) 78 | if err != nil { 79 | log.Fatal().Err(err).Msg("Failed to connect to Postgres database") 80 | os.Exit(1) 81 | } 82 | 83 | // Setup db 84 | log.Info().Msg("Checking to see if any database initialisation / migrations are needed") 85 | if err := RunDatabaseMigrations(); err != nil { 86 | log.Error().Err(err).Msg("Error initialising or migrating the database") 87 | os.Exit(1) 88 | } 89 | 90 | log.Info().Msg("Checking if interesting aircraft reference data needs updating from plane-alert-db") 91 | if err := UpsertPlaneAlertDb(pg); err != nil { 92 | log.Error().Msgf("Error updating interesting aircraft data: %v", err) 93 | os.Exit(1) 94 | } 95 | 96 | // Start API server in a separate goroutine 97 | log.Info().Msg("Starting API server") 98 | go func() { 99 | apiServer := NewAPIServer(pg) 100 | apiServer.Start() 101 | }() 102 | 103 | log.Info().Msg("Starting scheduled tasks") 104 | 105 | updateAircraftDataTicker := time.NewTicker(2 * time.Second) 106 | updateStatisticsTicker := time.NewTicker(120 * time.Second) 107 | updateRegistrationsTicker := time.NewTicker(30 * time.Second) 108 | updateRoutesTicker := time.NewTicker(300 * time.Second) 109 | updateInterestingSeenTicker := time.NewTicker(120 * time.Second) 110 | 111 | // Welcome to skystats 112 | if banner, err := os.ReadFile("../docs/logo/skystats_ascii.txt"); err == nil { 113 | log.Info().Msg("\n" + string(banner)) 114 | } 115 | log.Info().Msg("Welcome to Skystats!") 116 | 117 | defer func() { 118 | log.Info().Msg("Closing database connection") 119 | updateAircraftDataTicker.Stop() 120 | updateStatisticsTicker.Stop() 121 | updateRegistrationsTicker.Stop() 122 | updateRoutesTicker.Stop() 123 | updateInterestingSeenTicker.Stop() 124 | pg.Close() 125 | }() 126 | 127 | for { 128 | select { 129 | case <-updateAircraftDataTicker.C: 130 | log.Debug().Msg("Update Aircraft") 131 | updateAircraftDatabase(pg) 132 | case <-updateStatisticsTicker.C: 133 | log.Debug().Msg("Update Statistics") 134 | updateMeasurementStatistics(pg) 135 | case <-updateRegistrationsTicker.C: 136 | log.Debug().Msg("Update Aircraft Registration") 137 | updateRegistrations(pg) 138 | case <-updateRoutesTicker.C: 139 | log.Debug().Msg("Update Routes") 140 | updateRoutes(pg) 141 | case <-updateInterestingSeenTicker.C: 142 | log.Debug().Msg("Update Interesting Seen") 143 | updateInterestingSeen(pg) 144 | } 145 | } 146 | 147 | } 148 | 149 | func checkFlags() { 150 | flag.Parse() 151 | if showVersion { 152 | showVersionExit() 153 | } 154 | } 155 | 156 | func setLogLevel() { 157 | switch os.Getenv("LOG_LEVEL") { 158 | case "DEBUG": 159 | zerolog.SetGlobalLevel(zerolog.DebugLevel) 160 | log.Info().Msg("Log level set to DEBUG") 161 | case "INFO": 162 | zerolog.SetGlobalLevel(zerolog.InfoLevel) 163 | log.Info().Msg("Log level set to INFO") 164 | case "WARN": 165 | zerolog.SetGlobalLevel(zerolog.WarnLevel) 166 | log.Warn().Msg("Log level set to WARN") 167 | case "ERROR": 168 | zerolog.SetGlobalLevel(zerolog.ErrorLevel) 169 | log.Error().Msg("Log level set to ERROR") 170 | default: 171 | zerolog.SetGlobalLevel(zerolog.InfoLevel) 172 | log.Info().Msg("Log level not set or invalid, defaulting to INFO") 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /core/registrations.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "strings" 9 | 10 | "github.com/jackc/pgx/v5" 11 | "github.com/rs/zerolog/log" 12 | ) 13 | 14 | func updateRegistrations(pg *postgres) { 15 | 16 | aircrafts := unprocessedRegistrations(pg) 17 | 18 | if len(aircrafts) == 0 { 19 | return 20 | } 21 | 22 | existing, new := checkRegistrationExists(pg, aircrafts) 23 | 24 | if len(new) > 50 { 25 | new = new[:50] 26 | } 27 | 28 | var registrations []RegistrationInfo 29 | 30 | for _, aircraft := range new { 31 | 32 | registration, err := getRegistration(aircraft) 33 | 34 | if err != nil { 35 | log.Error().Err(err).Msg("Error fetching registration for " + aircraft.Hex) 36 | continue 37 | } 38 | 39 | if registration.Response.Aircraft.ModeS == "" { 40 | log.Debug().Msgf("No registration found for %s \n", aircraft.Hex) 41 | existing = append(existing, aircraft) 42 | continue 43 | } 44 | 45 | registrations = append(registrations, *registration) 46 | 47 | existing = append(existing, aircraft) 48 | 49 | } 50 | 51 | insertRegistrations(pg, registrations) 52 | 53 | MarkProcessed(pg, "registration_processed", existing) 54 | 55 | } 56 | 57 | func insertRegistrations(pg *postgres, registrations []RegistrationInfo) { 58 | 59 | batch := &pgx.Batch{} 60 | 61 | for _, registration := range registrations { 62 | insertStatement := ` 63 | INSERT INTO registration_data ( 64 | type, 65 | icao_type, 66 | manufacturer, 67 | mode_s, 68 | registration, 69 | registered_owner_country_iso_name, 70 | registered_owner_country_name, 71 | registered_owner_operator_flag_code, 72 | registered_owner, 73 | url_photo, 74 | url_photo_thumbnail) 75 | VALUES ( 76 | $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) 77 | ON CONFLICT (mode_s) 78 | DO UPDATE SET 79 | type = EXCLUDED.type, 80 | icao_type = EXCLUDED.icao_type, 81 | manufacturer = EXCLUDED.manufacturer, 82 | registration = EXCLUDED.registration, 83 | registered_owner_country_iso_name = EXCLUDED.registered_owner_country_iso_name, 84 | registered_owner_country_name = EXCLUDED.registered_owner_country_name, 85 | registered_owner_operator_flag_code = EXCLUDED.registered_owner_operator_flag_code, 86 | registered_owner = EXCLUDED.registered_owner, 87 | url_photo = EXCLUDED.url_photo, 88 | url_photo_thumbnail = EXCLUDED.url_photo_thumbnail` 89 | 90 | batch.Queue(insertStatement, 91 | registration.Response.Aircraft.Type, 92 | registration.Response.Aircraft.IcaoType, 93 | registration.Response.Aircraft.Manufacturer, 94 | strings.ToLower(registration.Response.Aircraft.ModeS), 95 | registration.Response.Aircraft.Registration, 96 | registration.Response.Aircraft.RegisteredOwnerCountryIsoName, 97 | registration.Response.Aircraft.RegisteredOwnerCountryName, 98 | registration.Response.Aircraft.RegisteredOwnerOperatorFlagCode, 99 | registration.Response.Aircraft.RegisteredOwner, 100 | registration.Response.Aircraft.URLPhoto, 101 | registration.Response.Aircraft.URLPhotoThumbnail) 102 | } 103 | 104 | br := pg.db.SendBatch(context.Background(), batch) 105 | defer br.Close() 106 | 107 | for i := 0; i < len(registrations); i++ { 108 | _, err := br.Exec() 109 | if err != nil { 110 | log.Error().Err(err).Msg("insertRegistrations() - Unable to insert data") 111 | } 112 | } 113 | 114 | } 115 | 116 | func getRegistration(aircraft Aircraft) (*RegistrationInfo, error) { 117 | 118 | url := "https://api.adsbdb.com/v0/aircraft/" 119 | url += aircraft.Hex 120 | 121 | response, err := http.Get(url) 122 | 123 | if err != nil { 124 | return nil, err 125 | } 126 | 127 | data, err := io.ReadAll(response.Body) 128 | 129 | if err != nil { 130 | return nil, err 131 | } 132 | 133 | var registrationResponse RegistrationInfo 134 | json.Unmarshal(data, ®istrationResponse) 135 | 136 | return ®istrationResponse, nil 137 | 138 | } 139 | 140 | func unprocessedRegistrations(pg *postgres) []Aircraft { 141 | 142 | query := ` 143 | SELECT id, hex 144 | FROM aircraft_data 145 | WHERE 146 | hex != '' AND 147 | registration_processed = false 148 | ORDER BY first_seen ASC` 149 | 150 | rows, err := pg.db.Query(context.Background(), query) 151 | 152 | if err != nil { 153 | log.Error().Err(err).Msg("unprocessedRegistrations() - Error querying db") 154 | return nil 155 | } 156 | defer rows.Close() 157 | 158 | var aircrafts []Aircraft 159 | 160 | for rows.Next() { 161 | 162 | var aircraft Aircraft 163 | 164 | err := rows.Scan( 165 | &aircraft.Id, 166 | &aircraft.Hex, 167 | ) 168 | 169 | if err != nil { 170 | log.Error().Err(err).Msg("unprocessedRegistrations() - Error scanning rows") 171 | return nil 172 | } 173 | 174 | aircrafts = append(aircrafts, aircraft) 175 | } 176 | 177 | log.Debug().Msgf("Aircrafts that have not have registration processed: %d", len(aircrafts)) 178 | return aircrafts 179 | } 180 | 181 | func checkRegistrationExists(pg *postgres, aircraftToProcess []Aircraft) (existing []Aircraft, new []Aircraft) { 182 | 183 | var hexValues []string 184 | for _, a := range aircraftToProcess { 185 | hexValues = append(hexValues, a.Hex) 186 | } 187 | 188 | existingRegistrations := make(map[string]*Aircraft) 189 | 190 | query := ` 191 | SELECT id, mode_s 192 | FROM registration_data 193 | WHERE mode_s = ANY($1::text[])` 194 | 195 | rows, err := pg.db.Query(context.Background(), query, hexValues) 196 | 197 | if err != nil { 198 | log.Error().Err(err).Msg("checkRegistrationExists() - Error querying db") 199 | return nil, nil 200 | } 201 | defer rows.Close() 202 | 203 | for rows.Next() { 204 | var registration Aircraft 205 | err := rows.Scan( 206 | ®istration.Id, 207 | ®istration.Hex, 208 | ) 209 | 210 | if err != nil { 211 | log.Error().Err(err).Msg("checkRegistrationExists() - Error scanning rows") 212 | continue 213 | } 214 | 215 | existingRegistrations[registration.Hex] = ®istration 216 | } 217 | 218 | for _, a := range aircraftToProcess { 219 | if _, ok := existingRegistrations[a.Hex]; ok { 220 | existing = append(existing, a) 221 | } else { 222 | new = append(new, a) 223 | } 224 | } 225 | 226 | return existing, new 227 | 228 | } 229 | -------------------------------------------------------------------------------- /core/models.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | type Response struct { 10 | Now float64 `json:"now"` 11 | Messages int `json:"messages"` 12 | Aircraft []Aircraft `json:"aircraft"` 13 | } 14 | type Aircraft struct { 15 | Id int 16 | Hex string `json:"hex"` 17 | Type string `json:"type"` 18 | Flight string `json:"flight"` 19 | R string `json:"r"` 20 | T string `json:"t"` 21 | AltBaro int `json:"alt_baro"` 22 | AltGeom int `json:"alt_geom"` 23 | Gs float64 `json:"gs"` 24 | Ias int `json:"ias"` 25 | Tas int `json:"tas"` 26 | Track float64 `json:"track"` 27 | BaroRate int `json:"baro_rate"` 28 | NavQnh float64 `json:"nav_qnh"` 29 | NavAltitudeMcp int `json:"nav_altitude_mcp"` 30 | NavHeading float64 `json:"nav_heading"` 31 | Lat float64 `json:"lat"` 32 | Lon float64 `json:"lon"` 33 | Nic int `json:"nic"` 34 | Rc int `json:"rc"` 35 | SeenPos float64 `json:"seen_pos"` 36 | RDst float64 `json:"r_dst"` 37 | RDir float64 `json:"r_dir"` 38 | Version int `json:"version"` 39 | NicBaro int `json:"nic_baro"` 40 | NacP int `json:"nac_p"` 41 | NacV int `json:"nac_v"` 42 | Sil int `json:"sil"` 43 | SilType string `json:"sil_type"` 44 | Alert int `json:"alert"` 45 | Spi int `json:"spi"` 46 | Mlat []any `json:"mlat"` 47 | Tisb []any `json:"tisb"` 48 | Messages int `json:"messages"` 49 | Seen float64 `json:"seen"` 50 | Rssi int `json:"rssi"` 51 | DbFlags int `json:"dbFlags"` 52 | Squawk string `json:"squawk"` 53 | Category string `json:"category"` 54 | FirstSeen time.Time 55 | FirstSeenEpoch float64 56 | LastSeen time.Time 57 | LastSeenEpoch float64 58 | LastSeenLat sql.NullFloat64 59 | LastSeenLon sql.NullFloat64 60 | LastSeenDistance sql.NullFloat64 61 | DestinationDistance sql.NullFloat64 62 | LowestProcessed bool 63 | HighestProcessed bool 64 | FastestProcessed bool 65 | SlowestProcessed bool 66 | } 67 | 68 | type InterestingAircraft struct { 69 | Icao string 70 | Registration sql.NullString 71 | Operator sql.NullString 72 | Type sql.NullString 73 | IcaoType sql.NullString 74 | Group sql.NullString 75 | Tag1 sql.NullString 76 | Tag2 sql.NullString 77 | Tag3 sql.NullString 78 | Category sql.NullString 79 | Link sql.NullString 80 | ImageLink1 sql.NullString 81 | ImageLink2 sql.NullString 82 | ImageLink3 sql.NullString 83 | ImageLink4 sql.NullString 84 | Hex string 85 | Flight string 86 | R string 87 | T string 88 | AltBaro int 89 | AltGeom int 90 | Gs float64 91 | Ias int 92 | Tas int 93 | Track float64 94 | BaroRate int 95 | Lat float64 96 | Lon float64 97 | Alert int 98 | DbFlags int 99 | Seen time.Time 100 | SeenEpoch float64 101 | } 102 | 103 | // Flight string sometimes has trailing whitespace 104 | func (r *Response) TrimFlightStrings() { 105 | for i := range r.Aircraft { 106 | r.Aircraft[i].Flight = strings.TrimSpace(r.Aircraft[i].Flight) 107 | } 108 | } 109 | 110 | type RegistrationInfo struct { 111 | Response struct { 112 | Aircraft struct { 113 | Type string `json:"type"` 114 | IcaoType string `json:"icao_type"` 115 | Manufacturer string `json:"manufacturer"` 116 | ModeS string `json:"mode_s"` 117 | Registration string `json:"registration"` 118 | RegisteredOwnerCountryIsoName string `json:"registered_owner_country_iso_name"` 119 | RegisteredOwnerCountryName string `json:"registered_owner_country_name"` 120 | RegisteredOwnerOperatorFlagCode string `json:"registered_owner_operator_flag_code"` 121 | RegisteredOwner string `json:"registered_owner"` 122 | URLPhoto any `json:"url_photo"` 123 | URLPhotoThumbnail any `json:"url_photo_thumbnail"` 124 | } `json:"aircraft"` 125 | } `json:"response"` 126 | } 127 | 128 | type RouteInfo struct { 129 | AirportCodesIata string `json:"_airport_codes_iata"` 130 | Airports []struct { 131 | AltFeet float64 `json:"alt_feet"` 132 | AltMeters float64 `json:"alt_meters"` 133 | CountryIso2 string `json:"countryiso2"` 134 | Iata string `json:"iata"` 135 | Icao string `json:"icao"` 136 | Lat float64 `json:"lat"` 137 | Location string `json:"location"` 138 | Lon float64 `json:"lon"` 139 | Name string `json:"name"` 140 | } `json:"_airports"` 141 | AirlineCode string `json:"airline_code"` 142 | AirportCodes string `json:"airport_codes"` 143 | Callsign string `json:"callsign"` 144 | Number string `json:"number"` 145 | Plausible bool `json:"plausible"` 146 | } 147 | 148 | type RouteAPIPlane struct { 149 | Callsign string `json:"callsign"` 150 | Lat float64 `json:"lat"` 151 | Lng float64 `json:"lng"` 152 | } 153 | 154 | type RouteAPIRequest struct { 155 | Planes []RouteAPIPlane `json:"planes"` 156 | } 157 | 158 | type ChartPoint struct { 159 | X time.Time `json:"x"` 160 | Y float64 `json:"y"` 161 | } 162 | 163 | type ChartSeries struct { 164 | ID string `json:"id"` 165 | Label string `json:"label"` 166 | Unit string `json:"unit,omitempty"` 167 | Points []ChartPoint `json:"points"` 168 | } 169 | 170 | type ChartXAxisMeta struct { 171 | Type string `json:"type"` 172 | Timezone string `json:"timezone,omitempty"` 173 | Unit string `json:"unit,omitempty"` 174 | } 175 | 176 | type ChartMeta struct { 177 | GeneratedAt time.Time `json:"generated_at"` 178 | } 179 | 180 | type ChartResponse struct { 181 | Series []ChartSeries `json:"series"` 182 | X ChartXAxisMeta `json:"x"` 183 | Meta ChartMeta `json:"meta"` 184 | } 185 | -------------------------------------------------------------------------------- /core/stats-interesting.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/jackc/pgx/v5" 8 | "github.com/rs/zerolog/log" 9 | ) 10 | 11 | func updateInterestingSeen(pg *postgres) { 12 | 13 | aircrafts := unprocessedInteresting(pg) 14 | 15 | if len(aircrafts) == 0 { 16 | return 17 | } 18 | 19 | if len(aircrafts) > 1000 { 20 | aircrafts = aircrafts[:1000] 21 | } 22 | 23 | aircraftsMap := make(map[string]Aircraft) 24 | aircraftsHex := make([]string, 0, len(aircrafts)) 25 | 26 | for _, aircraft := range aircrafts { 27 | aircraftsMap[strings.ToUpper(aircraft.Hex)] = aircraft 28 | aircraftsHex = append(aircraftsHex, strings.ToUpper(aircraft.Hex)) 29 | } 30 | 31 | query := ` 32 | SELECT 33 | icao, 34 | registration, 35 | operator, 36 | type, 37 | icao_type, 38 | "group", 39 | tag1, 40 | tag2, 41 | tag3, 42 | category, 43 | link, 44 | image_link_1, 45 | image_link_2, 46 | image_link_3, 47 | image_link_4 48 | FROM interesting_aircraft 49 | WHERE icao = ANY($1::text[])` 50 | 51 | rows, err := pg.db.Query(context.Background(), query, aircraftsHex) 52 | 53 | if err != nil { 54 | log.Error().Err(err).Msg("updateInterestingSeen() - Error querying db") 55 | return 56 | } 57 | 58 | defer rows.Close() 59 | 60 | var interestingAircrafts []InterestingAircraft 61 | 62 | for rows.Next() { 63 | var interestingAircraft InterestingAircraft 64 | err := rows.Scan( 65 | &interestingAircraft.Icao, 66 | &interestingAircraft.Registration, 67 | &interestingAircraft.Operator, 68 | &interestingAircraft.Type, 69 | &interestingAircraft.IcaoType, 70 | &interestingAircraft.Group, 71 | &interestingAircraft.Tag1, 72 | &interestingAircraft.Tag2, 73 | &interestingAircraft.Tag3, 74 | &interestingAircraft.Category, 75 | &interestingAircraft.Link, 76 | &interestingAircraft.ImageLink1, 77 | &interestingAircraft.ImageLink2, 78 | &interestingAircraft.ImageLink3, 79 | &interestingAircraft.ImageLink4, 80 | ) 81 | 82 | if err != nil { 83 | log.Error().Err(err).Msg("updateInterestingSeen() - Error scanning rows") 84 | continue 85 | } 86 | 87 | interestingAircrafts = append(interestingAircrafts, interestingAircraft) 88 | } 89 | 90 | for i := range interestingAircrafts { 91 | interestingAircraft := &interestingAircrafts[i] 92 | if aircraft, ok := aircraftsMap[interestingAircraft.Icao]; ok { 93 | interestingAircraft.Hex = aircraft.Hex 94 | interestingAircraft.Flight = aircraft.Flight 95 | interestingAircraft.R = aircraft.R 96 | interestingAircraft.T = aircraft.T 97 | interestingAircraft.AltBaro = aircraft.AltBaro 98 | interestingAircraft.AltGeom = aircraft.AltGeom 99 | interestingAircraft.Gs = aircraft.Gs 100 | interestingAircraft.Ias = aircraft.Ias 101 | interestingAircraft.Tas = aircraft.Tas 102 | interestingAircraft.Track = aircraft.Track 103 | interestingAircraft.BaroRate = aircraft.BaroRate 104 | interestingAircraft.Lat = aircraft.Lat 105 | interestingAircraft.Lon = aircraft.Lon 106 | interestingAircraft.Alert = aircraft.Alert 107 | interestingAircraft.DbFlags = aircraft.DbFlags 108 | interestingAircraft.Seen = aircraft.FirstSeen 109 | interestingAircraft.SeenEpoch = aircraft.FirstSeenEpoch 110 | } 111 | } 112 | 113 | log.Debug().Msgf("Interesting aircrafts found: %d", len(interestingAircrafts)) 114 | 115 | batch := &pgx.Batch{} 116 | 117 | for _, aircraft := range interestingAircrafts { 118 | insertStatement := ` 119 | INSERT INTO interesting_aircraft_seen ( 120 | icao, 121 | registration, 122 | operator, 123 | type, 124 | icao_type, 125 | "group", 126 | tag1, 127 | tag2, 128 | tag3, 129 | category, 130 | link, 131 | image_link_1, 132 | image_link_2, 133 | image_link_3, 134 | image_link_4, 135 | hex, 136 | flight, 137 | r, 138 | t, 139 | alt_baro, 140 | alt_geom, 141 | gs, 142 | ias, 143 | tas, 144 | track, 145 | baro_rate, 146 | lat, 147 | lon, 148 | alert, 149 | db_flags, 150 | seen, 151 | seen_epoch) 152 | VALUES ( 153 | $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 154 | $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, 155 | $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32)` 156 | 157 | batch.Queue(insertStatement, 158 | aircraft.Icao, 159 | aircraft.Registration, 160 | aircraft.Operator, 161 | aircraft.Type, 162 | aircraft.IcaoType, 163 | aircraft.Group, 164 | aircraft.Tag1, 165 | aircraft.Tag2, 166 | aircraft.Tag3, 167 | aircraft.Category, 168 | aircraft.Link, 169 | aircraft.ImageLink1, 170 | aircraft.ImageLink2, 171 | aircraft.ImageLink3, 172 | aircraft.ImageLink4, 173 | aircraft.Hex, 174 | aircraft.Flight, 175 | aircraft.R, 176 | aircraft.T, 177 | aircraft.AltBaro, 178 | aircraft.AltGeom, 179 | aircraft.Gs, 180 | aircraft.Ias, 181 | aircraft.Tas, 182 | aircraft.Track, 183 | aircraft.BaroRate, 184 | aircraft.Lat, 185 | aircraft.Lon, 186 | aircraft.Alert, 187 | aircraft.DbFlags, 188 | aircraft.Seen, 189 | aircraft.SeenEpoch) 190 | } 191 | 192 | br := pg.db.SendBatch(context.Background(), batch) 193 | defer br.Close() 194 | 195 | for i := 0; i < len(interestingAircrafts); i++ { 196 | _, err := br.Exec() 197 | if err != nil { 198 | log.Error().Err(err).Msg("insertRegistrations() - Unable to insert data") 199 | } 200 | } 201 | 202 | MarkProcessed(pg, "interesting_processed", aircrafts) 203 | 204 | } 205 | 206 | func unprocessedInteresting(pg *postgres) []Aircraft { 207 | 208 | query := ` 209 | SELECT id, 210 | hex, 211 | flight, 212 | r, 213 | t, 214 | alt_baro, 215 | alt_geom, 216 | gs, 217 | ias, 218 | tas, 219 | track, 220 | baro_rate, 221 | lat, 222 | lon, 223 | alert, 224 | db_flags, 225 | first_seen, 226 | first_seen_epoch 227 | FROM aircraft_data 228 | WHERE 229 | hex != '' AND 230 | interesting_processed = false 231 | ORDER BY first_seen ASC` 232 | 233 | rows, err := pg.db.Query(context.Background(), query) 234 | 235 | if err != nil { 236 | log.Error().Err(err).Msg("unprocessedInteresting() - Error querying db") 237 | return nil 238 | } 239 | 240 | defer rows.Close() 241 | 242 | var aircrafts []Aircraft 243 | 244 | for rows.Next() { 245 | 246 | var aircraft Aircraft 247 | 248 | err := rows.Scan( 249 | &aircraft.Id, 250 | &aircraft.Hex, 251 | &aircraft.Flight, 252 | &aircraft.R, 253 | &aircraft.T, 254 | &aircraft.AltBaro, 255 | &aircraft.AltGeom, 256 | &aircraft.Gs, 257 | &aircraft.Ias, 258 | &aircraft.Tas, 259 | &aircraft.Track, 260 | &aircraft.BaroRate, 261 | &aircraft.Lat, 262 | &aircraft.Lon, 263 | &aircraft.Alert, 264 | &aircraft.DbFlags, 265 | &aircraft.FirstSeen, 266 | &aircraft.FirstSeenEpoch, 267 | ) 268 | 269 | if err != nil { 270 | log.Error().Err(err).Msg("unprocessedInteresting() - Error scanning rows") 271 | return nil 272 | } 273 | 274 | aircrafts = append(aircrafts, aircraft) 275 | } 276 | 277 | log.Debug().Msgf("Aircrafts that have not have interesting processed: %d", len(aircrafts)) 278 | return aircrafts 279 | } 280 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | sf metadata linter logo 3 |
4 |
5 |
6 | Skystats is an application to retrieve, store, and display interesting aircraft ADS-B data received via an SDR. 7 |

8 | ⚠️ Skystats is still in early development and considered "beta" so expect bugs and instability. 9 |
10 |
11 | 12 | 13 | ## Overview 14 | 15 | * [Go](https://go.dev/) app with [PostgreSQL](https://www.postgresql.org/) database and [Svelte](https://svelte.dev/) + [DaisyUI](https://daisyui.com/) front end 16 | * ADS-B data is received via [adsb-ultrafeeder](https://github.com/sdr-enthusiasts/docker-adsb-ultrafeeder) / [readsb](https://github.com/wiedehopf/readsb), running on a Raspberry Pi 4 attached to an SDR + aerial ([see it here!](docs/setup/aerial.jpg)) 17 | * The application consumes aircraft data from the readsb [aircraft.json](https://github.com/wiedehopf/readsb-githist/blob/dev/README-json.md) file 18 | * A [gin](https://gin-gonic.com/) API surfaces information from the postgres database to the web frontend 19 | * Registration & routing data is retrieved from the [adsb-db](https://github.com/mrjackwills/adsbdb) API 20 | * "Interesting" aircraft are identified via a local copy of the [plane-alert-db](https://github.com/sdr-enthusiasts/plane-alert-db) 21 | 22 | ## Features 23 | * "Above Me" - live view of 5 nearest aircraft with routing information 24 | * Total aircraft seen (past hour, day, all time) 25 | * Total aircraft with route data 26 | * Unique Countries 27 | * Unique Airports 28 | * Top Airlines 29 | * Top Airports (Domestic, International) 30 | * Top Countries (Origin, Destination) 31 | * Top Routes 32 | * Interesting Aircraft (Miiltary, Government, Police, Civilian) 33 | * Fastest Aircraft 34 | * Slowest Aircraft 35 | * Highest Aircraft 36 | * Lowest Aircraft 37 | 38 | ## Setup 39 | 40 | ### Running in Docker (recommended) 41 | 42 | Using Skystats in Docker is the easiest way to get up and running. 43 | 44 | * Copy the contents of [`.env.example`](.env.example) into a new file called `.env` 45 | * Populate `.env` with all required values. See [Environment Variables](#environment-variables) 46 | * Download [`example.compose.yml`](example.compose.yml) and name it compose.yml 47 | * Run `docker compose up -d` 48 | * The interface should be available on `localhost:5173` where localhost is the IP of the docker host 49 | 50 | Alternatively there are some [Advanced Setup](#advanced-setup) options. 51 |
52 | 53 | ### Environment Variables 54 | 55 | | Environment Variable | Description | Example | 56 | |---|---|---| 57 | | READSB_AIRCRAFT_JSON | URL of where readsb [aircraft.json](https://github.com/wiedehopf/readsb-githist/blob/dev/README-json.md) is being served e.g. http://yourhost:yourport/data/aircraft.json | `http://192.168.1.100:8080/data/aircraft.json` | 58 | | DB_HOST | Postgres host. If running in docker this should be the name of the postgres container. If running locally it should be the IP/hostname of wherever postgres is hosted. | Docker: `skystats-db`
Local: `192.168.1.10` | 59 | | DB_PORT | Postgres port | `5432` | 60 | | DB_USER | Postgres username | `user` | 61 | | DB_PASSWORD | Postgres password | `1234` | 62 | | DB_NAME | Postgres database name | `skystats_db` | 63 | | DOMESTIC_COUNTRY_ISO | ISO 2-letter country code of the country your receiver is in - used to generate the "Domestic Airport" stats. | `GB` | 64 | | LAT | Lattitude of your receiver. | `XX.XXXXXX` | 65 | | LON | Longitude of your receiver. | `YY.YYYYYY` | 66 | | RADIUS | Distance in km from your receiver that you want to record aircraft. Set to a distance greater than that of your receiver to capture all aircraft. | `1000` | 67 | | ABOVE_RADIUS | Radius for the "Above Timeline"
**Note: currently only 20km supported.** | `20` | 68 | | LOG_LEVEL | Logging level e.g. `TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`.

The default/recommended level is `INFO`, but `DEBUG` can be used if needed.

Note: `DEBUG` will also cause the Gin API server to run in "debug" mode with more verbose logging. |`INFO`| 69 | 70 |
71 | 72 | ## Support / Feeback 73 | 74 | Skystats is still under early active development. If you're having issues getting it running, or have suggestions/feedback, then the best place to get support is on the [#skystats](https://discord.gg/znkBr2eyev) channel in the [SDR Enthusiasts Discord](https://discord.gg/86Tyxjcd94). Alternatively you can raise an [Issue](https://github.com/tomcarman/skystats/issues) in GitHub, and I'll do my best to support. 75 |
76 | 77 | 78 | ## Advanced Setup 79 | 80 | The intention is for Skystats to be run via the [provided Docker containers](#setup). However, if you want to run locally or if you want to contribute by developing, see below guidance. 81 | 82 | ### Running locally 83 | 84 | * BYO postgres database (in a Docker container or other) 85 | * Copy the contents of [`.env.example`](.env.example) into a new file called `.env` 86 | * Populate `.env` with all required values. See [Environment Variables](#environment-variables) 87 | * Download the latest [release binary](https://github.com/tomcarman/skystats/releases) for your OS/arch 88 | * Execute e.g. `./skystats` 89 | * TODO: Instructions to run the webserver 90 | 91 | 92 | ### Compile from source (e.g. to develop) 93 | * BYO postgres database (in a Docker container or other) 94 | * Clone this repository 95 | * Copy the contents of `.env.example` into a new file called `.env` 96 | * Populate `.env` with all required values. See [Environment Variables](#environment-variables) 97 | * Change to the `core` folder e.g. `cd core` 98 | * Compile with `go build -o skystats-daemon` 99 | * Run the app `./skystats-daemon` 100 | * It can be terminated via `kill $(cat skystats/core/skystats.pid)` 101 | * Run the webserver 102 | * Change to the /web directory e.g. `cd ../web` 103 | * Start the webserver with `npm run dev -- --host` 104 | * See [`build`](/scripts/build) for a script to automate some of this 105 | 106 | ## Advanced Use Cases 107 | 108 | ### Custom plane-alert-db csv 109 | 110 | If you live in an area where you frequently see planes that you are not interested in, you can provide a custom version of [plane-alert-db](https://github.com/sdr-enthusiasts/plane-alert-db). 111 | 112 | This expects a file identical in structure to https://github.com/sdr-enthusiasts/plane-alert-db/blob/main/plane-alert-db-images.csv 113 | 114 | Add the following to the `.env` file: 115 | ``` 116 | PLANE_DB_URL=some/custom/location/plane-alert-db.csv 117 | ``` 118 | 119 | And the following to `compose.yml` under the `skystats` service: 120 | ``` 121 | - PLANE_DB_URL=${PLANE_DB_URL} 122 | ``` 123 | 124 | **⚠️ The format of the csv must match the format of combined plane data + image file from plane-alert-db** 125 | 126 |
127 | 128 | ## Screenshots 129 | 130 | ### Home 131 | ![Home](docs/screenshots/1_Home.png) 132 |
133 | ![AboveMeModal](docs/screenshots/2_AboveMeModal.png) 134 |
135 | 136 | ### Route Stats 137 | ![RouteStats](docs/screenshots/3_RouteStats.png) 138 |
139 | 140 | ### Interesting Aircraft 141 | ![InterestingSeen](docs/screenshots/4_InterestingStats.png) 142 |
143 | ![InterestingModal](docs/screenshots/5_InterestingModal.png) 144 |
145 | 146 | ### Motion Stats 147 | ![MotionStats](docs/screenshots/6_MotionStats.png) 148 | -------------------------------------------------------------------------------- /migrations/000001_initial_schema.up.sql: -------------------------------------------------------------------------------- 1 | -- Initial schema for Skystats database 2 | 3 | -- Create aircraft_data table 4 | CREATE TABLE aircraft_data ( 5 | id SERIAL PRIMARY KEY, 6 | hex VARCHAR, 7 | flight VARCHAR, 8 | first_seen TIMESTAMPTZ, 9 | first_seen_epoch BIGINT, 10 | last_seen TIMESTAMPTZ, 11 | last_seen_epoch BIGINT, 12 | type VARCHAR, 13 | r VARCHAR, 14 | t VARCHAR, 15 | alt_baro INTEGER, 16 | alt_geom INTEGER, 17 | gs NUMERIC(6,1), 18 | ias INTEGER, 19 | tas INTEGER, 20 | mach NUMERIC(5,3), 21 | track NUMERIC(5,2), 22 | track_rate NUMERIC(5,2), 23 | roll NUMERIC(5,2), 24 | mag_heading NUMERIC(5,2), 25 | true_heading NUMERIC(5,2), 26 | baro_rate INTEGER, 27 | geom_rate INTEGER, 28 | squawk VARCHAR, 29 | emergency VARCHAR, 30 | nav_qnh NUMERIC(7,1), 31 | nav_altitude_mcp INTEGER, 32 | nav_heading NUMERIC(5,2), 33 | nav_modes TEXT[], 34 | lat NUMERIC(9,6), 35 | lon NUMERIC(9,6), 36 | nic INTEGER, 37 | rc INTEGER, 38 | seen_pos NUMERIC(9,3), 39 | r_dst NUMERIC(8,3), 40 | r_dir NUMERIC(8,3), 41 | version INTEGER, 42 | nic_baro INTEGER, 43 | nac_p INTEGER, 44 | nac_v INTEGER, 45 | sil INTEGER, 46 | sil_type VARCHAR, 47 | gva INTEGER, 48 | sda INTEGER, 49 | alert INTEGER, 50 | spi INTEGER, 51 | mlat TEXT[], 52 | tisb TEXT[], 53 | messages INTEGER, 54 | seen NUMERIC(8,3), 55 | rssi NUMERIC(6,1), 56 | highest_aircraft_processed BOOLEAN DEFAULT false, 57 | lowest_aircraft_processed BOOLEAN DEFAULT false, 58 | fastest_aircraft_processed BOOLEAN DEFAULT false, 59 | slowest_aircraft_processed BOOLEAN DEFAULT false, 60 | db_flags INTEGER, 61 | route_processed BOOLEAN DEFAULT false, 62 | registration_processed BOOLEAN DEFAULT false, 63 | interesting_processed BOOLEAN DEFAULT false, 64 | last_seen_lat NUMERIC(9,6), 65 | last_seen_lon NUMERIC(9,6), 66 | last_seen_distance NUMERIC(6,2), 67 | last_seen_bearing NUMERIC(6,3), 68 | destination_distance NUMERIC(8,2) 69 | ); 70 | 71 | -- Create registration_data table 72 | CREATE TABLE registration_data ( 73 | id SERIAL PRIMARY KEY, 74 | type VARCHAR, 75 | icao_type VARCHAR, 76 | manufacturer VARCHAR, 77 | mode_s VARCHAR, 78 | registration VARCHAR, 79 | registered_owner_country_iso_name VARCHAR, 80 | registered_owner_country_name VARCHAR, 81 | registered_owner_operator_flag_code VARCHAR, 82 | registered_owner VARCHAR, 83 | url_photo VARCHAR, 84 | url_photo_thumbnail VARCHAR, 85 | CONSTRAINT mode_s_unique UNIQUE (mode_s) 86 | ); 87 | 88 | -- Create fastest_aircraft table 89 | CREATE TABLE fastest_aircraft ( 90 | id SERIAL PRIMARY KEY, 91 | hex VARCHAR, 92 | flight VARCHAR, 93 | registration VARCHAR, 94 | type VARCHAR, 95 | first_seen TIMESTAMPTZ, 96 | last_seen TIMESTAMPTZ, 97 | ground_speed NUMERIC(6,1), 98 | indicated_air_speed INTEGER, 99 | true_air_speed INTEGER, 100 | CONSTRAINT fastest_aircraft_unique_hex_first_seen UNIQUE (hex, first_seen) 101 | ); 102 | 103 | -- Create route_data table 104 | CREATE TABLE route_data ( 105 | id SERIAL PRIMARY KEY, 106 | route_callsign VARCHAR, 107 | route_callsign_icao VARCHAR, 108 | route_callsign_iata VARCHAR, 109 | airline_name VARCHAR, 110 | airline_icao VARCHAR, 111 | airline_iata VARCHAR, 112 | airline_country VARCHAR, 113 | airline_country_iso VARCHAR, 114 | airline_callsign VARCHAR, 115 | origin_country_iso_name VARCHAR, 116 | origin_country_name VARCHAR, 117 | origin_elevation INTEGER, 118 | origin_iata_code VARCHAR, 119 | origin_icao_code VARCHAR, 120 | origin_latitude NUMERIC(9,6), 121 | origin_longitude NUMERIC(9,6), 122 | origin_municipality VARCHAR, 123 | origin_name VARCHAR, 124 | destination_country_iso_name VARCHAR, 125 | destination_country_name VARCHAR, 126 | destination_elevation INTEGER, 127 | destination_iata_code VARCHAR, 128 | destination_icao_code VARCHAR, 129 | destination_latitude NUMERIC(9,6), 130 | destination_longitude NUMERIC(9,6), 131 | destination_municipality VARCHAR, 132 | destination_name VARCHAR, 133 | last_updated TIMESTAMP, 134 | route_distance NUMERIC(8,2), 135 | CONSTRAINT route_callsign_unique UNIQUE (route_callsign) 136 | ); 137 | 138 | -- Create highest_aircraft table 139 | CREATE TABLE highest_aircraft ( 140 | id SERIAL PRIMARY KEY, 141 | hex VARCHAR, 142 | flight VARCHAR, 143 | registration VARCHAR, 144 | type VARCHAR, 145 | first_seen TIMESTAMPTZ, 146 | last_seen TIMESTAMPTZ, 147 | barometric_altitude INTEGER, 148 | geometric_altitude INTEGER, 149 | CONSTRAINT highest_aircraft_unique_hex_first_seen UNIQUE (hex, first_seen) 150 | ); 151 | 152 | -- Create interesting_aircraft table 153 | CREATE TABLE interesting_aircraft ( 154 | icao TEXT, 155 | registration TEXT, 156 | operator TEXT, 157 | type TEXT, 158 | icao_type TEXT, 159 | "group" TEXT, 160 | tag1 TEXT, 161 | tag2 TEXT, 162 | tag3 TEXT, 163 | category TEXT, 164 | link TEXT, 165 | image_link_1 TEXT, 166 | image_link_2 TEXT, 167 | image_link_3 TEXT, 168 | image_link_4 TEXT 169 | ); 170 | 171 | -- Create interesting_aircraft_seen table 172 | CREATE TABLE interesting_aircraft_seen ( 173 | icao TEXT, 174 | registration TEXT, 175 | operator TEXT, 176 | type TEXT, 177 | icao_type TEXT, 178 | "group" TEXT, 179 | tag1 TEXT, 180 | tag2 TEXT, 181 | tag3 TEXT, 182 | category TEXT, 183 | link TEXT, 184 | image_link_1 TEXT, 185 | image_link_2 TEXT, 186 | image_link_3 TEXT, 187 | image_link_4 TEXT, 188 | hex TEXT, 189 | flight TEXT, 190 | seen TIMESTAMPTZ, 191 | seen_epoch BIGINT, 192 | r TEXT, 193 | t TEXT, 194 | alt_baro INTEGER, 195 | alt_geom INTEGER, 196 | gs NUMERIC(6,1), 197 | ias INTEGER, 198 | tas INTEGER, 199 | track NUMERIC(5,2), 200 | baro_rate INTEGER, 201 | squawk TEXT, 202 | emergency TEXT, 203 | lat NUMERIC(9,6), 204 | lon NUMERIC(9,6), 205 | alert INTEGER, 206 | db_flags INTEGER 207 | ); 208 | 209 | -- Create lowest_aircraft table 210 | CREATE TABLE lowest_aircraft ( 211 | id SERIAL PRIMARY KEY, 212 | hex VARCHAR, 213 | flight VARCHAR, 214 | registration VARCHAR, 215 | type VARCHAR, 216 | first_seen TIMESTAMPTZ, 217 | last_seen TIMESTAMPTZ, 218 | barometric_altitude INTEGER, 219 | geometric_altitude INTEGER, 220 | CONSTRAINT lowest_aircraft_unique_hex_first_seen UNIQUE (hex, first_seen) 221 | ); 222 | 223 | -- Create slowest_aircraft table 224 | CREATE TABLE slowest_aircraft ( 225 | id SERIAL PRIMARY KEY, 226 | hex VARCHAR, 227 | flight VARCHAR, 228 | registration VARCHAR, 229 | type VARCHAR, 230 | first_seen TIMESTAMPTZ, 231 | last_seen TIMESTAMPTZ, 232 | ground_speed NUMERIC(6,1), 233 | indicated_air_speed INTEGER, 234 | true_air_speed INTEGER, 235 | CONSTRAINT slowest_aircraft_unique_hex_first_seen UNIQUE (hex, first_seen) 236 | ); 237 | 238 | -- Create indexes 239 | CREATE INDEX aircraft_data_hex ON aircraft_data USING btree (hex) WITH (deduplicate_items='true'); 240 | CREATE INDEX idx_aircraft_data_hex ON aircraft_data USING btree (hex); 241 | CREATE INDEX idx_aircraft_data_hex_last_seen ON aircraft_data USING btree (hex, last_seen DESC); -------------------------------------------------------------------------------- /core/countries.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type CountryLookup struct { 4 | data map[string]string 5 | } 6 | 7 | func CountryIsoToName() *CountryLookup { 8 | return &CountryLookup{ 9 | data: map[string]string{ 10 | "AF": "Afghanistan", 11 | "AX": "Åland Islands", 12 | "AL": "Albania", 13 | "DZ": "Algeria", 14 | "AS": "American Samoa", 15 | "AD": "Andorra", 16 | "AO": "Angola", 17 | "AI": "Anguilla", 18 | "AQ": "Antarctica", 19 | "AG": "Antigua and Barbuda", 20 | "AR": "Argentina", 21 | "AM": "Armenia", 22 | "AW": "Aruba", 23 | "AU": "Australia", 24 | "AT": "Austria", 25 | "AZ": "Azerbaijan", 26 | "BS": "Bahamas", 27 | "BH": "Bahrain", 28 | "BD": "Bangladesh", 29 | "BB": "Barbados", 30 | "BY": "Belarus", 31 | "BE": "Belgium", 32 | "BZ": "Belize", 33 | "BJ": "Benin", 34 | "BM": "Bermuda", 35 | "BT": "Bhutan", 36 | "BO": "Bolivia, Plurinational State of", 37 | "BQ": "Bonaire, Sint Eustatius and Saba", 38 | "BA": "Bosnia and Herzegovina", 39 | "BW": "Botswana", 40 | "BV": "Bouvet Island", 41 | "BR": "Brazil", 42 | "IO": "British Indian Ocean Territory", 43 | "BN": "Brunei Darussalam", 44 | "BG": "Bulgaria", 45 | "BF": "Burkina Faso", 46 | "BI": "Burundi", 47 | "KH": "Cambodia", 48 | "CM": "Cameroon", 49 | "CA": "Canada", 50 | "CV": "Cape Verde", 51 | "KY": "Cayman Islands", 52 | "CF": "Central African Republic", 53 | "TD": "Chad", 54 | "CL": "Chile", 55 | "CN": "China", 56 | "CX": "Christmas Island", 57 | "CC": "Cocos (Keeling) Islands", 58 | "CO": "Colombia", 59 | "KM": "Comoros", 60 | "CG": "Congo", 61 | "CD": "Congo, the Democratic Republic of the", 62 | "CK": "Cook Islands", 63 | "CR": "Costa Rica", 64 | "CI": "Côte d'Ivoire", 65 | "HR": "Croatia", 66 | "CU": "Cuba", 67 | "CW": "Curaçao", 68 | "CY": "Cyprus", 69 | "CZ": "Czech Republic", 70 | "DK": "Denmark", 71 | "DJ": "Djibouti", 72 | "DM": "Dominica", 73 | "DO": "Dominican Republic", 74 | "EC": "Ecuador", 75 | "EG": "Egypt", 76 | "SV": "El Salvador", 77 | "GQ": "Equatorial Guinea", 78 | "ER": "Eritrea", 79 | "EE": "Estonia", 80 | "ET": "Ethiopia", 81 | "FK": "Falkland Islands (Malvinas)", 82 | "FO": "Faroe Islands", 83 | "FJ": "Fiji", 84 | "FI": "Finland", 85 | "FR": "France", 86 | "GF": "French Guiana", 87 | "PF": "French Polynesia", 88 | "TF": "French Southern Territories", 89 | "GA": "Gabon", 90 | "GM": "Gambia", 91 | "GE": "Georgia", 92 | "DE": "Germany", 93 | "GH": "Ghana", 94 | "GI": "Gibraltar", 95 | "GR": "Greece", 96 | "GL": "Greenland", 97 | "GD": "Grenada", 98 | "GP": "Guadeloupe", 99 | "GU": "Guam", 100 | "GT": "Guatemala", 101 | "GG": "Guernsey", 102 | "GN": "Guinea", 103 | "GW": "Guinea-Bissau", 104 | "GY": "Guyana", 105 | "HT": "Haiti", 106 | "HM": "Heard Island and McDonald Islands", 107 | "VA": "Holy See (Vatican City State)", 108 | "HN": "Honduras", 109 | "HK": "Hong Kong", 110 | "HU": "Hungary", 111 | "IS": "Iceland", 112 | "IN": "India", 113 | "ID": "Indonesia", 114 | "IR": "Iran, Islamic Republic of", 115 | "IQ": "Iraq", 116 | "IE": "Ireland", 117 | "IM": "Isle of Man", 118 | "IL": "Israel", 119 | "IT": "Italy", 120 | "JM": "Jamaica", 121 | "JP": "Japan", 122 | "JE": "Jersey", 123 | "JO": "Jordan", 124 | "KZ": "Kazakhstan", 125 | "KE": "Kenya", 126 | "KI": "Kiribati", 127 | "KP": "Korea, Democratic People's Republic of", 128 | "KR": "Korea, Republic of", 129 | "KW": "Kuwait", 130 | "KG": "Kyrgyzstan", 131 | "LA": "Lao People's Democratic Republic", 132 | "LV": "Latvia", 133 | "LB": "Lebanon", 134 | "LS": "Lesotho", 135 | "LR": "Liberia", 136 | "LY": "Libya", 137 | "LI": "Liechtenstein", 138 | "LT": "Lithuania", 139 | "LU": "Luxembourg", 140 | "MO": "Macao", 141 | "MK": "Macedonia, the Former Yugoslav Republic of", 142 | "MG": "Madagascar", 143 | "MW": "Malawi", 144 | "MY": "Malaysia", 145 | "MV": "Maldives", 146 | "ML": "Mali", 147 | "MT": "Malta", 148 | "MH": "Marshall Islands", 149 | "MQ": "Martinique", 150 | "MR": "Mauritania", 151 | "MU": "Mauritius", 152 | "YT": "Mayotte", 153 | "MX": "Mexico", 154 | "FM": "Micronesia, Federated States of", 155 | "MD": "Moldova, Republic of", 156 | "MC": "Monaco", 157 | "MN": "Mongolia", 158 | "ME": "Montenegro", 159 | "MS": "Montserrat", 160 | "MA": "Morocco", 161 | "MZ": "Mozambique", 162 | "MM": "Myanmar", 163 | "nan": "Namibia", 164 | "NR": "Nauru", 165 | "NP": "Nepal", 166 | "NL": "Netherlands", 167 | "NC": "New Caledonia", 168 | "NZ": "New Zealand", 169 | "NI": "Nicaragua", 170 | "NE": "Niger", 171 | "NG": "Nigeria", 172 | "NU": "Niue", 173 | "NF": "Norfolk Island", 174 | "MP": "Northern Mariana Islands", 175 | "NO": "Norway", 176 | "OM": "Oman", 177 | "PK": "Pakistan", 178 | "PW": "Palau", 179 | "PS": "Palestine, State of", 180 | "PA": "Panama", 181 | "PG": "Papua New Guinea", 182 | "PY": "Paraguay", 183 | "PE": "Peru", 184 | "PH": "Philippines", 185 | "PN": "Pitcairn", 186 | "PL": "Poland", 187 | "PT": "Portugal", 188 | "PR": "Puerto Rico", 189 | "QA": "Qatar", 190 | "RE": "Réunion", 191 | "RO": "Romania", 192 | "RU": "Russian Federation", 193 | "RW": "Rwanda", 194 | "BL": "Saint Barthélemy", 195 | "SH": "Saint Helena, Ascension and Tristan da Cunha", 196 | "KN": "Saint Kitts and Nevis", 197 | "LC": "Saint Lucia", 198 | "MF": "Saint Martin (French part)", 199 | "PM": "Saint Pierre and Miquelon", 200 | "VC": "Saint Vincent and the Grenadines", 201 | "WS": "Samoa", 202 | "SM": "San Marino", 203 | "ST": "Sao Tome and Principe", 204 | "SA": "Saudi Arabia", 205 | "SN": "Senegal", 206 | "RS": "Serbia", 207 | "SC": "Seychelles", 208 | "SL": "Sierra Leone", 209 | "SG": "Singapore", 210 | "SX": "Sint Maarten (Dutch part)", 211 | "SK": "Slovakia", 212 | "SI": "Slovenia", 213 | "SB": "Solomon Islands", 214 | "SO": "Somalia", 215 | "ZA": "South Africa", 216 | "GS": "South Georgia and the South Sandwich Islands", 217 | "SS": "South Sudan", 218 | "ES": "Spain", 219 | "LK": "Sri Lanka", 220 | "SD": "Sudan", 221 | "SR": "Suriname", 222 | "SJ": "Svalbard and Jan Mayen", 223 | "SZ": "Eswatini", 224 | "SE": "Sweden", 225 | "CH": "Switzerland", 226 | "SY": "Syrian Arab Republic", 227 | "TW": "Taiwan, Province of China", 228 | "TJ": "Tajikistan", 229 | "TZ": "Tanzania, United Republic of", 230 | "TH": "Thailand", 231 | "TL": "Timor-Leste", 232 | "TG": "Togo", 233 | "TK": "Tokelau", 234 | "TO": "Tonga", 235 | "TT": "Trinidad and Tobago", 236 | "TN": "Tunisia", 237 | "TR": "Turkey", 238 | "TM": "Turkmenistan", 239 | "TC": "Turks and Caicos Islands", 240 | "TV": "Tuvalu", 241 | "UG": "Uganda", 242 | "UA": "Ukraine", 243 | "AE": "United Arab Emirates", 244 | "GB": "United Kingdom", 245 | "US": "United States", 246 | "UM": "United States Minor Outlying Islands", 247 | "UY": "Uruguay", 248 | "UZ": "Uzbekistan", 249 | "VU": "Vanuatu", 250 | "VE": "Venezuela, Bolivarian Republic of", 251 | "VN": "Viet Nam", 252 | "VG": "Virgin Islands, British", 253 | "VI": "Virgin Islands, U.S.", 254 | "WF": "Wallis and Futuna", 255 | "EH": "Western Sahara", 256 | "YE": "Yemen", 257 | "ZM": "Zambia", 258 | "ZW": "Zimbabwe", 259 | }, 260 | } 261 | } 262 | 263 | func (c *CountryLookup) GetName(iso string) (string, bool) { 264 | name, ok := c.data[iso] 265 | return name, ok 266 | } 267 | -------------------------------------------------------------------------------- /core/db-plane-alert-data.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "encoding/csv" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "os" 12 | 13 | "github.com/jackc/pgx/v5" 14 | "github.com/rs/zerolog/log" 15 | ) 16 | 17 | type Row struct { 18 | ICAO string 19 | Registration *string 20 | Operator *string 21 | Type *string 22 | ICAOType *string 23 | Group *string 24 | Tag1 *string 25 | Tag2 *string 26 | Tag3 *string 27 | Category *string 28 | Link *string 29 | Image1 *string 30 | Image2 *string 31 | Image3 *string 32 | Image4 *string 33 | } 34 | 35 | type GitHubAPIResponse struct { 36 | Files []struct { 37 | SHA string `json:"sha"` 38 | Filename string `json:"name"` 39 | } 40 | } 41 | 42 | func UpsertPlaneAlertDb(pg *postgres) error { 43 | 44 | planeAlertUrl, isCustomPlaneAlertUrl := os.LookupEnv("PLANE_DB_URL") 45 | 46 | if !isCustomPlaneAlertUrl { 47 | planeAlertUrl = "https://raw.githubusercontent.com/sdr-enthusiasts/plane-alert-db/refs/heads/main/plane-alert-db-images.csv" 48 | } 49 | 50 | needsUpdating, commitHash, err := checkForUpdates(pg, isCustomPlaneAlertUrl) 51 | if err != nil { 52 | log.Warn().Msgf("Error checking for updates: %v", err) 53 | log.Warn().Msg("Updating despite error checking.") 54 | needsUpdating = true 55 | commitHash = "failed_to_get_commit_hash" 56 | } 57 | 58 | if !needsUpdating { 59 | log.Info().Msg("No new data in plane-alert-db, skipping update") 60 | return nil 61 | } 62 | 63 | log.Info().Msg("New data found in plane-alert-db, updating interesting aircraft reference data") 64 | 65 | planeAlertRecords, err := fetchCSVData(planeAlertUrl) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | headers := getHeaderMap(planeAlertRecords[0]) 71 | 72 | data := map[string]*Row{} 73 | 74 | for _, record := range planeAlertRecords[1:] { 75 | 76 | icao := record[headers["$ICAO"]] 77 | if icao == "" { 78 | continue 79 | } 80 | 81 | row := &Row{} 82 | row.ICAO = icao 83 | row.Registration = getValue(record[headers["$Registration"]]) 84 | row.Operator = getValue(record[headers["$Operator"]]) 85 | row.Type = getValue(record[headers["$Type"]]) 86 | row.ICAOType = getValue(record[headers["$ICAO Type"]]) 87 | row.Group = getValue(record[headers["#CMPG"]]) 88 | row.Tag1 = getValue(record[headers["$Tag 1"]]) 89 | row.Tag2 = getValue(record[headers["$#Tag 2"]]) 90 | row.Tag3 = getValue(record[headers["$#Tag 3"]]) 91 | row.Category = getValue(record[headers["Category"]]) 92 | row.Link = getValue(record[headers["$#Link"]]) 93 | row.Image1 = getValue(record[headers["#ImageLink"]]) 94 | row.Image2 = getValue(record[headers["#ImageLink2"]]) 95 | row.Image3 = getValue(record[headers["#ImageLink3"]]) 96 | row.Image4 = getValue(record[headers["#ImageLink4"]]) 97 | 98 | data[icao] = row 99 | } 100 | 101 | insertStatement := ` 102 | INSERT INTO interesting_aircraft ( 103 | icao, registration, operator, "type", icao_type, 104 | "group", tag1, tag2, tag3, category, link, 105 | image_link_1, image_link_2, image_link_3, image_link_4, commit_hash 106 | ) VALUES ( 107 | $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16 108 | ) 109 | ON CONFLICT (icao) DO UPDATE SET 110 | registration = EXCLUDED.registration, 111 | operator = EXCLUDED.operator, 112 | "type" = EXCLUDED."type", 113 | icao_type = EXCLUDED.icao_type, 114 | "group" = EXCLUDED."group", 115 | tag1 = EXCLUDED.tag1, 116 | tag2 = EXCLUDED.tag2, 117 | tag3 = EXCLUDED.tag3, 118 | category = EXCLUDED.category, 119 | link = EXCLUDED.link, 120 | image_link_1 = EXCLUDED.image_link_1, 121 | image_link_2 = EXCLUDED.image_link_2, 122 | image_link_3 = EXCLUDED.image_link_3, 123 | image_link_4 = EXCLUDED.image_link_4, 124 | commit_hash = EXCLUDED.commit_hash 125 | ` 126 | 127 | batch := &pgx.Batch{} 128 | for _, row := range data { 129 | batch.Queue( 130 | insertStatement, 131 | row.ICAO, 132 | row.Registration, 133 | row.Operator, 134 | row.Type, 135 | row.ICAOType, 136 | row.Group, 137 | row.Tag1, 138 | row.Tag2, 139 | row.Tag3, 140 | row.Category, 141 | row.Link, 142 | row.Image1, 143 | row.Image2, 144 | row.Image3, 145 | row.Image4, 146 | commitHash, 147 | ) 148 | } 149 | 150 | br := pg.db.SendBatch(context.Background(), batch) 151 | defer br.Close() 152 | 153 | for range data { 154 | _, err := br.Exec() 155 | if err != nil { 156 | return fmt.Errorf("Error upserting interesting_aircraft data: %w", err) 157 | } 158 | } 159 | 160 | log.Info().Msgf("Succesfully upserted %d interesting aircraft records from plane-alert-db", len(data)) 161 | 162 | return nil 163 | } 164 | 165 | func fetchCSVData(url string) ([][]string, error) { 166 | resp, err := http.Get(url) 167 | if err != nil { 168 | return nil, fmt.Errorf("Error retrieving the CSV for plane-alert-db: %w", err) 169 | } 170 | defer resp.Body.Close() 171 | 172 | reader := csv.NewReader(resp.Body) 173 | records, err := reader.ReadAll() 174 | if err != nil { 175 | return nil, fmt.Errorf("Error reading CSV records for plane-alert-db: %w", err) 176 | } 177 | 178 | return records, nil 179 | } 180 | 181 | func getHeaderMap(headers []string) map[string]int { 182 | headerMap := make(map[string]int) 183 | for i, header := range headers { 184 | headerMap[header] = i 185 | } 186 | return headerMap 187 | } 188 | 189 | func getValue(s string) *string { 190 | if s == "" { 191 | return nil 192 | } 193 | return &s 194 | } 195 | 196 | func checkForUpdates(pg *postgres, isCustom bool) (needsUpdating bool, commitHash string, err error) { 197 | 198 | var exists bool 199 | err = pg.db.QueryRow(context.Background(), "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'interesting_aircraft')").Scan(&exists) 200 | if err != nil || !exists { 201 | return false, "", fmt.Errorf("Error checking for interesting_aircraft table: %w", err) 202 | } 203 | 204 | // If exists and is empty, then always needs updating 205 | var count int 206 | err = pg.db.QueryRow(context.Background(), "SELECT COUNT(*) FROM interesting_aircraft").Scan(&count) 207 | if err != nil { 208 | return false, "", fmt.Errorf("Error checking interesting_aircraft table: %w", err) 209 | } 210 | 211 | if count == 0 { 212 | return true, "", nil 213 | } else if isCustom { // If not empty and custom, skip updates, abort 214 | return false, "", nil 215 | } 216 | 217 | // Otherwise, check if newer commit hash 218 | var existingCommitHash sql.NullString 219 | err = pg.db.QueryRow(context.Background(), "SELECT commit_hash FROM interesting_aircraft LIMIT 1").Scan(&existingCommitHash) 220 | if err != nil { 221 | return false, "", fmt.Errorf("Error checking interesting_aircraft table: %w", err) 222 | } 223 | 224 | latestCommitHash, err := getLatestCommitHash() 225 | if err != nil { 226 | return false, "", fmt.Errorf("Error getting latest commit hash: %w", err) 227 | } 228 | 229 | if !existingCommitHash.Valid || latestCommitHash != existingCommitHash.String { 230 | return true, latestCommitHash, nil 231 | } 232 | 233 | return false, "", nil 234 | } 235 | 236 | func getLatestCommitHash() (string, error) { 237 | resp, err := http.Get("https://api.github.com/repos/sdr-enthusiasts/plane-alert-db/contents/") 238 | if err != nil { 239 | return "", fmt.Errorf("Error retrieving latest commit hash for plane-alert-db: %w", err) 240 | } 241 | defer resp.Body.Close() 242 | 243 | body, err := io.ReadAll(resp.Body) 244 | if err != nil { 245 | return "", fmt.Errorf("Error reading response body for latest commit hash for plane-alert-db: %w", err) 246 | } 247 | if resp.StatusCode != 200 { 248 | return "", fmt.Errorf("Error when getting hash for plane-alert-db-images.csv: github api returned http code: %s", resp.Status) 249 | } 250 | 251 | var commitResponse GitHubAPIResponse 252 | err = json.Unmarshal(body, &commitResponse.Files) 253 | if err != nil { 254 | return "", fmt.Errorf("Error parsing JSON response for latest commit hash: %w", err) 255 | } 256 | 257 | for _, file := range commitResponse.Files { 258 | if file.Filename == "plane-alert-db-images.csv" { 259 | return file.SHA, nil 260 | } 261 | } 262 | log.Error().Msgf("getLatestCommitHash failed, printing commitResponse\n%+v\n", commitResponse) 263 | log.Error().Msgf("getLatestCommitHash failed, printing body\n%s\n", body) 264 | return "", fmt.Errorf("Error finding plane-alert-db-images.csv commit hash") 265 | } 266 | -------------------------------------------------------------------------------- /web/src/components/charts/AircraftByPeriod.svelte: -------------------------------------------------------------------------------- 1 | 263 | 264 | {#if loading} 265 |
266 | 267 |
268 | {:else if error} 269 |
270 | 271 | Something went wrong: {error} 272 |
273 | {:else if !chartData} 274 |
275 | 276 | No aircraft type data available 277 |
278 | {:else} 279 |
280 | 281 |
282 | {/if} 283 | -------------------------------------------------------------------------------- /web/src/components/InterestingAircraft.svelte: -------------------------------------------------------------------------------- 1 | 73 | 74 |
75 |
76 |
77 |
78 | {#if loading} 79 |
80 | 81 |
82 | {:else if error} 83 |
84 | 85 | Something went wrong: {error} 86 |
87 | {:else if data.length === 0} 88 |
89 | 90 | No data available 91 |
92 | {:else} 93 | 94 |
95 | {#if icon} 96 |
97 | 98 |
99 | {/if} 100 |

{title}

101 |
102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | {#each data as aircraft} 114 | showAircraftModal(aircraft)}> 115 | 116 | 117 | 118 | 119 | 120 | {/each} 121 | 122 |
RegOperatorTypeLast Seen
{aircraft.registration}{aircraft.operator}{aircraft.type}{aircraft.seen ? new Date(aircraft.seen).toLocaleString() : '-'}
123 | {/if} 124 |
125 |
126 |
127 |
128 | 129 | 130 | 131 | 204 | 207 | 208 | -------------------------------------------------------------------------------- /core/routes.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "time" 11 | 12 | "github.com/jackc/pgx/v5" 13 | "github.com/rs/zerolog/log" 14 | "github.com/tomcarman/skystats/data" 15 | ) 16 | 17 | func getDistanceBetweenAirports(origin []float64, destination []float64) *float64 { 18 | distance := getRuler().Distance(origin, destination) 19 | return &distance 20 | } 21 | 22 | func updateRoutes(pg *postgres) { 23 | 24 | aircrafts := unprocessedRoutes(pg) 25 | 26 | if len(aircrafts) == 0 { 27 | return 28 | } 29 | 30 | if len(aircrafts) > 100 { 31 | aircrafts = aircrafts[:100] 32 | } 33 | 34 | existing, new := checkRouteExists(pg, aircrafts) 35 | 36 | routes, err := getRoutes(new) 37 | if err != nil { 38 | log.Error().Err(err).Msg("Error getting routes") 39 | return 40 | } 41 | 42 | insertRoutes(pg, routes) 43 | 44 | existing = append(existing, new...) 45 | MarkProcessed(pg, "route_processed", existing) 46 | 47 | } 48 | 49 | func unprocessedRoutes(pg *postgres) []Aircraft { 50 | 51 | query := ` 52 | SELECT id, flight, last_seen_lat, last_seen_lon 53 | FROM aircraft_data 54 | WHERE 55 | hex != '' AND 56 | flight != '' AND 57 | route_processed = false 58 | ORDER BY first_seen ASC` 59 | 60 | rows, err := pg.db.Query(context.Background(), query) 61 | 62 | if err != nil { 63 | log.Error().Err(err).Msg("unprocessedRoutes() - Error querying db") 64 | return nil 65 | } 66 | defer rows.Close() 67 | 68 | var aircrafts []Aircraft 69 | 70 | for rows.Next() { 71 | 72 | var aircraft Aircraft 73 | 74 | err := rows.Scan( 75 | &aircraft.Id, 76 | &aircraft.Flight, 77 | &aircraft.LastSeenLat, 78 | &aircraft.LastSeenLon, 79 | ) 80 | 81 | if err != nil { 82 | log.Error().Err(err).Msg("unprocessedRoutes() - Error scanning rows") 83 | return nil 84 | } 85 | 86 | aircrafts = append(aircrafts, aircraft) 87 | } 88 | 89 | log.Debug().Msgf("Aircrafts that have not have routes processed: %d", len(aircrafts)) 90 | return aircrafts 91 | } 92 | 93 | func checkRouteExists(pg *postgres, aircraftToProcess []Aircraft) (existing []Aircraft, new []Aircraft) { 94 | 95 | var callsignValues []string 96 | for _, a := range aircraftToProcess { 97 | callsignValues = append(callsignValues, a.Flight) 98 | } 99 | 100 | existingRoutes := make(map[string]*Aircraft) 101 | 102 | query := ` 103 | SELECT id, route_callsign 104 | FROM route_data 105 | WHERE route_callsign = ANY($1::text[]) 106 | AND last_updated IS NOT NULL 107 | AND last_updated > NOW() - INTERVAL '1 hour'` 108 | 109 | rows, err := pg.db.Query(context.Background(), query, callsignValues) 110 | 111 | if err != nil { 112 | log.Error().Err(err).Msg("checkRouteExists() - Error querying db") 113 | return nil, nil 114 | } 115 | defer rows.Close() 116 | 117 | for rows.Next() { 118 | var route Aircraft 119 | err := rows.Scan( 120 | &route.Id, 121 | &route.Flight, 122 | ) 123 | 124 | if err != nil { 125 | log.Error().Err(err).Msg("checkRouteExists() - Error scanning rows") 126 | continue 127 | } 128 | 129 | existingRoutes[route.Flight] = &route 130 | } 131 | 132 | for _, a := range aircraftToProcess { 133 | if _, ok := existingRoutes[a.Flight]; ok { 134 | existing = append(existing, a) 135 | } else { 136 | new = append(new, a) 137 | } 138 | } 139 | 140 | return existing, new 141 | 142 | } 143 | 144 | func getRoutes(aircrafts []Aircraft) ([]RouteInfo, error) { 145 | 146 | requestBodyData := buildRouteApiRequestBody(aircrafts) 147 | requestBodyJson, err := json.Marshal(requestBodyData) 148 | 149 | if err != nil { 150 | return nil, err 151 | } 152 | 153 | url := "http://adsb.im/api/0/routeset" 154 | 155 | req, err := http.NewRequest("POST", url, bytes.NewReader(requestBodyJson)) 156 | if err != nil { 157 | return nil, err 158 | } 159 | 160 | req.Header.Set("Content-Type", "application/json") 161 | req.Header.Set("User-Agent", fmt.Sprintf("Skystats/%s", version)) 162 | req.Header.Set("Accept", "application/json") 163 | 164 | client := &http.Client{ 165 | Timeout: 5 * time.Second, 166 | } 167 | 168 | resp, err := client.Do(req) 169 | if err != nil { 170 | return nil, err 171 | } 172 | defer resp.Body.Close() 173 | 174 | body, err := io.ReadAll(resp.Body) 175 | if err != nil { 176 | return nil, err 177 | } 178 | 179 | var routes []RouteInfo 180 | err = json.Unmarshal(body, &routes) 181 | if err != nil { 182 | return nil, err 183 | } 184 | 185 | return routes, nil 186 | } 187 | 188 | func insertRoutes(pg *postgres, routes []RouteInfo) { 189 | 190 | batch := &pgx.Batch{} 191 | last_updated := time.Now().UTC().Format("2006-01-02 15:04:05-07") 192 | countryLookup := CountryIsoToName() 193 | queuedCount := 0 194 | 195 | for _, route := range routes { 196 | 197 | // Skip callsigns that were not matched 198 | if route.AirportCodesIata == "unknown" { 199 | continue 200 | } 201 | 202 | // Skip any "unplausible" routes 203 | if route.Plausible == false { 204 | continue 205 | } 206 | 207 | // Skip any empty or multihop routes - for now 208 | if route.Airports == nil || len(route.Airports) != 2 { 209 | continue 210 | } 211 | 212 | origin := route.Airports[0] 213 | destination := route.Airports[1] 214 | 215 | // Get country names from ISO codes 216 | originCountry, _ := countryLookup.GetName(origin.CountryIso2) 217 | destinationCountry, _ := countryLookup.GetName(destination.CountryIso2) 218 | 219 | // Get airline info from code 220 | airline, _ := data.LookupAirline(route.AirlineCode) 221 | 222 | // Calculate distance between airports 223 | var distance *float64 224 | if origin.Lat != 0 && origin.Lon != 0 && 225 | destination.Lat != 0 && destination.Lon != 0 { 226 | distance = getDistanceBetweenAirports([]float64{origin.Lon, origin.Lat}, []float64{destination.Lon, destination.Lat}) 227 | } 228 | 229 | insertStatement := ` 230 | INSERT INTO route_data ( 231 | route_callsign, 232 | route_callsign_icao, 233 | airline_name, 234 | airline_icao, 235 | airline_iata, 236 | origin_country_iso_name, 237 | origin_country_name, 238 | origin_elevation, 239 | origin_iata_code, 240 | origin_icao_code, 241 | origin_latitude, 242 | origin_longitude, 243 | origin_municipality, 244 | origin_name, 245 | destination_country_iso_name, 246 | destination_country_name, 247 | destination_elevation, 248 | destination_iata_code, 249 | destination_icao_code, 250 | destination_latitude, 251 | destination_longitude, 252 | destination_municipality, 253 | destination_name, 254 | last_updated, 255 | route_distance) 256 | VALUES ( 257 | $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, 258 | $16, $17, $18, $19, $20, $21, $22, $23, $24, $25) 259 | ON CONFLICT (route_callsign) 260 | DO UPDATE SET 261 | route_callsign = EXCLUDED.route_callsign, 262 | route_callsign_icao = EXCLUDED.route_callsign_icao, 263 | airline_name = EXCLUDED.airline_name, 264 | airline_icao = EXCLUDED.airline_icao, 265 | airline_iata = EXCLUDED.airline_iata, 266 | origin_country_iso_name = EXCLUDED.origin_country_iso_name, 267 | origin_country_name = EXCLUDED.origin_country_name, 268 | origin_elevation = EXCLUDED.origin_elevation, 269 | origin_iata_code = EXCLUDED.origin_iata_code, 270 | origin_icao_code = EXCLUDED.origin_icao_code, 271 | origin_latitude = EXCLUDED.origin_latitude, 272 | origin_longitude = EXCLUDED.origin_longitude, 273 | origin_municipality = EXCLUDED.origin_municipality, 274 | origin_name = EXCLUDED.origin_name, 275 | destination_country_iso_name = EXCLUDED.destination_country_iso_name, 276 | destination_country_name = EXCLUDED.destination_country_name, 277 | destination_elevation = EXCLUDED.destination_elevation, 278 | destination_iata_code = EXCLUDED.destination_iata_code, 279 | destination_icao_code = EXCLUDED.destination_icao_code, 280 | destination_latitude = EXCLUDED.destination_latitude, 281 | destination_longitude = EXCLUDED.destination_longitude, 282 | destination_municipality = EXCLUDED.destination_municipality, 283 | destination_name = EXCLUDED.destination_name, 284 | last_updated = EXCLUDED.last_updated, 285 | route_distance = EXCLUDED.route_distance` 286 | 287 | batch.Queue(insertStatement, 288 | route.Callsign, 289 | route.Callsign, 290 | airline.Name, 291 | route.AirlineCode, 292 | airline.IATA, 293 | origin.CountryIso2, 294 | originCountry, 295 | origin.AltFeet, 296 | origin.Iata, 297 | origin.Icao, 298 | origin.Lat, 299 | origin.Lon, 300 | origin.Location, 301 | origin.Name, 302 | destination.CountryIso2, 303 | destinationCountry, 304 | destination.AltFeet, 305 | destination.Iata, 306 | destination.Icao, 307 | destination.Lat, 308 | destination.Lon, 309 | destination.Location, 310 | destination.Name, 311 | last_updated, 312 | distance) 313 | queuedCount++ 314 | } 315 | 316 | br := pg.db.SendBatch(context.Background(), batch) 317 | defer br.Close() 318 | 319 | for i := 0; i < queuedCount; i++ { 320 | _, err := br.Exec() 321 | if err != nil { 322 | log.Error().Err(err).Msg("insertRoutes() - Unable to insert data") 323 | } 324 | } 325 | 326 | } 327 | 328 | func buildRouteApiRequestBody(aircrafts []Aircraft) RouteAPIRequest { 329 | 330 | aircraftsJson := make([]RouteAPIPlane, 0) 331 | 332 | for _, aircraft := range aircrafts { 333 | if aircraft.Flight != "" && aircraft.LastSeenLat.Valid && aircraft.LastSeenLon.Valid { 334 | aircraftsJson = append(aircraftsJson, RouteAPIPlane{ 335 | Callsign: aircraft.Flight, 336 | Lat: aircraft.LastSeenLat.Float64, 337 | Lng: aircraft.LastSeenLon.Float64, 338 | }) 339 | } 340 | } 341 | return RouteAPIRequest{Planes: aircraftsJson} 342 | } 343 | -------------------------------------------------------------------------------- /core/stats-motion.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "sort" 6 | 7 | "github.com/jackc/pgx/v5" 8 | "github.com/rs/zerolog/log" 9 | ) 10 | 11 | func updateMeasurementStatistics(pg *postgres) { 12 | 13 | aircrafts := getAircraftsForMeasurementStatistics(pg) 14 | 15 | updateLowestAircraft(pg, aircrafts) 16 | updateFastestAircraft(pg, aircrafts) 17 | updateHighestAircraft(pg, aircrafts) 18 | updateSlowestAircraft(pg, aircrafts) 19 | 20 | } 21 | 22 | func updateLowestAircraft(pg *postgres, aircrafts []Aircraft) { 23 | processedMetricName := "lowest_aircraft_processed" 24 | tableName := "lowest_aircraft" 25 | metricName := "barometric_altitude" 26 | 27 | var aircraftToProcess []Aircraft 28 | 29 | for _, aircraft := range aircrafts { 30 | if !aircraft.LowestProcessed { 31 | aircraftToProcess = append(aircraftToProcess, aircraft) 32 | } 33 | } 34 | 35 | if len(aircraftToProcess) == 0 { 36 | return 37 | } 38 | 39 | lowestAircraftCeiling := getLowestAircraftCeiling(pg) 40 | 41 | sort.Slice(aircraftToProcess, func(i, j int) bool { 42 | return aircraftToProcess[i].AltBaro < aircraftToProcess[j].AltBaro 43 | }) 44 | 45 | var aircraftsToInsert []Aircraft 46 | 47 | for _, aircraft := range aircraftToProcess { 48 | if aircraft.AltBaro < 1 { 49 | continue 50 | } 51 | if aircraft.AltBaro < lowestAircraftCeiling { 52 | aircraftsToInsert = append(aircraftsToInsert, aircraft) 53 | } else { 54 | break 55 | } 56 | } 57 | 58 | batch := &pgx.Batch{} 59 | 60 | for _, aircraft := range aircraftsToInsert { 61 | insertStatement := ` 62 | INSERT INTO lowest_aircraft ( 63 | hex, 64 | flight, 65 | registration, 66 | type, 67 | first_seen, 68 | last_seen, 69 | barometric_altitude, 70 | geometric_altitude) 71 | VALUES ( 72 | $1, $2, $3, $4, $5, $6, $7, $8) 73 | ON CONFLICT (hex, first_seen) 74 | DO UPDATE SET 75 | barometric_altitude = EXCLUDED.barometric_altitude, 76 | geometric_altitude = EXCLUDED.geometric_altitude, 77 | last_seen = EXCLUDED.last_seen` 78 | 79 | batch.Queue( 80 | insertStatement, 81 | aircraft.Hex, 82 | aircraft.Flight, 83 | aircraft.R, 84 | aircraft.T, 85 | aircraft.FirstSeen, 86 | aircraft.LastSeen, 87 | aircraft.AltBaro, 88 | aircraft.AltGeom) 89 | } 90 | 91 | br := pg.db.SendBatch(context.Background(), batch) 92 | defer br.Close() 93 | 94 | for i := 0; i < len(aircraftsToInsert); i++ { 95 | _, err := br.Exec() 96 | if err != nil { 97 | log.Error().Err(err).Msg("updateLowestAircraft() - Unable to insert data") 98 | } 99 | } 100 | DeleteExcessRows(pg, tableName, metricName, "DESC", 50) 101 | 102 | if len(aircraftToProcess) > 0 { 103 | MarkProcessed(pg, processedMetricName, aircraftToProcess) 104 | } 105 | 106 | } 107 | 108 | func updateHighestAircraft(pg *postgres, aircrafts []Aircraft) { 109 | 110 | processedMetricName := "highest_aircraft_processed" 111 | tableName := "highest_aircraft" 112 | metricName := "barometric_altitude" 113 | 114 | var aircraftToProcess []Aircraft 115 | 116 | for _, aircraft := range aircrafts { 117 | if !aircraft.HighestProcessed { 118 | aircraftToProcess = append(aircraftToProcess, aircraft) 119 | } 120 | } 121 | 122 | if len(aircraftToProcess) == 0 { 123 | return 124 | } 125 | 126 | highestAircraftFloor := getHighestAircraftFloor(pg) 127 | 128 | sort.Slice(aircraftToProcess, func(i, j int) bool { 129 | return aircraftToProcess[i].AltBaro > aircraftToProcess[j].AltBaro 130 | }) 131 | 132 | var aircraftsToInsert []Aircraft 133 | 134 | for _, aircraft := range aircraftToProcess { 135 | if aircraft.AltBaro > highestAircraftFloor { 136 | aircraftsToInsert = append(aircraftsToInsert, aircraft) 137 | } else { 138 | break 139 | } 140 | } 141 | 142 | batch := &pgx.Batch{} 143 | 144 | for _, aircraft := range aircraftsToInsert { 145 | insertStatement := ` 146 | INSERT INTO highest_aircraft ( 147 | hex, 148 | flight, 149 | registration, 150 | type, 151 | first_seen, 152 | last_seen, 153 | barometric_altitude, 154 | geometric_altitude) 155 | VALUES ( 156 | $1, $2, $3, $4, $5, $6, $7, $8) 157 | ON CONFLICT (hex, first_seen) 158 | DO UPDATE SET 159 | barometric_altitude = EXCLUDED.barometric_altitude, 160 | geometric_altitude = EXCLUDED.geometric_altitude, 161 | last_seen = EXCLUDED.last_seen` 162 | 163 | batch.Queue( 164 | insertStatement, 165 | aircraft.Hex, 166 | aircraft.Flight, 167 | aircraft.R, 168 | aircraft.T, 169 | aircraft.FirstSeen, 170 | aircraft.LastSeen, 171 | aircraft.AltBaro, 172 | aircraft.AltGeom) 173 | } 174 | 175 | br := pg.db.SendBatch(context.Background(), batch) 176 | defer br.Close() 177 | 178 | for i := 0; i < len(aircraftsToInsert); i++ { 179 | _, err := br.Exec() 180 | if err != nil { 181 | log.Error().Err(err).Msg("updateHighestAircraft() - Unable to insert data") 182 | } 183 | } 184 | 185 | DeleteExcessRows(pg, tableName, metricName, "ASC", 50) 186 | 187 | if len(aircraftToProcess) > 0 { 188 | MarkProcessed(pg, processedMetricName, aircraftToProcess) 189 | } 190 | } 191 | 192 | func updateSlowestAircraft(pg *postgres, aircrafts []Aircraft) { 193 | 194 | processedMetricName := "slowest_aircraft_processed" 195 | tableName := "slowest_aircraft" 196 | metricName := "ground_speed" 197 | 198 | var aircraftToProcess []Aircraft 199 | 200 | for _, aircraft := range aircrafts { 201 | if !aircraft.SlowestProcessed { 202 | aircraftToProcess = append(aircraftToProcess, aircraft) 203 | } 204 | } 205 | 206 | if len(aircraftToProcess) == 0 { 207 | return 208 | } 209 | 210 | slowestAircraftCeiling := getSlowestAircraftCeiling(pg) 211 | 212 | sort.Slice(aircraftToProcess, func(i, j int) bool { 213 | return aircraftToProcess[i].Gs < aircraftToProcess[j].Gs 214 | }) 215 | 216 | var aircraftsToInsert []Aircraft 217 | 218 | for _, aircraft := range aircraftToProcess { 219 | if aircraft.Gs < 1 { 220 | continue 221 | } 222 | 223 | if aircraft.Gs < slowestAircraftCeiling { 224 | aircraftsToInsert = append(aircraftsToInsert, aircraft) 225 | } else { 226 | break 227 | } 228 | } 229 | 230 | batch := &pgx.Batch{} 231 | 232 | for _, aircraft := range aircraftsToInsert { 233 | insertStatement := ` 234 | INSERT INTO slowest_aircraft ( 235 | hex, 236 | flight, 237 | registration, 238 | type, 239 | first_seen, 240 | last_seen, 241 | ground_speed, 242 | indicated_air_speed, 243 | true_air_speed) 244 | VALUES ( 245 | $1, $2, $3, $4, $5, $6, $7, $8, $9) 246 | ON CONFLICT (hex, first_seen) 247 | DO UPDATE SET 248 | ground_speed = EXCLUDED.ground_speed, 249 | indicated_air_speed = EXCLUDED.indicated_air_speed, 250 | true_air_speed = EXCLUDED.true_air_speed, 251 | last_seen = EXCLUDED.last_seen` 252 | 253 | batch.Queue( 254 | insertStatement, 255 | aircraft.Hex, 256 | aircraft.Flight, 257 | aircraft.R, 258 | aircraft.T, 259 | aircraft.FirstSeen, 260 | aircraft.LastSeen, 261 | aircraft.Gs, 262 | aircraft.Tas, 263 | aircraft.Ias) 264 | } 265 | 266 | br := pg.db.SendBatch(context.Background(), batch) 267 | defer br.Close() 268 | 269 | for i := 0; i < len(aircraftsToInsert); i++ { 270 | _, err := br.Exec() 271 | if err != nil { 272 | log.Error().Err(err).Msg("updateSlowestAircraft() - Unable to insert data") 273 | } 274 | } 275 | 276 | DeleteExcessRows(pg, tableName, metricName, "DESC", 50) 277 | 278 | if len(aircraftToProcess) > 0 { 279 | MarkProcessed(pg, processedMetricName, aircraftToProcess) 280 | } 281 | } 282 | 283 | func updateFastestAircraft(pg *postgres, aircrafts []Aircraft) { 284 | 285 | processedMetricName := "fastest_aircraft_processed" 286 | tableName := "fastest_aircraft" 287 | metricName := "ground_speed" 288 | 289 | var aircraftToProcess []Aircraft 290 | 291 | for _, aircraft := range aircrafts { 292 | if !aircraft.FastestProcessed { 293 | aircraftToProcess = append(aircraftToProcess, aircraft) 294 | } 295 | } 296 | 297 | if len(aircraftToProcess) == 0 { 298 | return 299 | } 300 | 301 | fastestAircraftFloor := getFastestAircraftFloor(pg) 302 | 303 | sort.Slice(aircraftToProcess, func(i, j int) bool { 304 | return aircraftToProcess[i].Gs > aircraftToProcess[j].Gs 305 | }) 306 | 307 | var aircraftsToInsert []Aircraft 308 | 309 | for _, aircraft := range aircraftToProcess { 310 | if aircraft.Gs > fastestAircraftFloor { 311 | aircraftsToInsert = append(aircraftsToInsert, aircraft) 312 | } else { 313 | break 314 | } 315 | } 316 | 317 | batch := &pgx.Batch{} 318 | 319 | for _, aircraft := range aircraftsToInsert { 320 | insertStatement := ` 321 | INSERT INTO fastest_aircraft ( 322 | hex, 323 | flight, 324 | registration, 325 | type, 326 | first_seen, 327 | last_seen, 328 | ground_speed, 329 | indicated_air_speed, 330 | true_air_speed) 331 | VALUES ( 332 | $1, $2, $3, $4, $5, $6, $7, $8, $9) 333 | ON CONFLICT (hex, first_seen) 334 | DO UPDATE SET 335 | ground_speed = EXCLUDED.ground_speed, 336 | indicated_air_speed = EXCLUDED.indicated_air_speed, 337 | true_air_speed = EXCLUDED.true_air_speed, 338 | last_seen = EXCLUDED.last_seen` 339 | 340 | batch.Queue( 341 | insertStatement, 342 | aircraft.Hex, 343 | aircraft.Flight, 344 | aircraft.R, 345 | aircraft.T, 346 | aircraft.FirstSeen, 347 | aircraft.LastSeen, 348 | aircraft.Gs, 349 | aircraft.Tas, 350 | aircraft.Ias) 351 | } 352 | 353 | br := pg.db.SendBatch(context.Background(), batch) 354 | defer br.Close() 355 | 356 | for i := 0; i < len(aircraftsToInsert); i++ { 357 | _, err := br.Exec() 358 | if err != nil { 359 | log.Error().Err(err).Msg("updateFastestAircraft() - Unable to insert data") 360 | } 361 | } 362 | 363 | DeleteExcessRows(pg, tableName, metricName, "ASC", 50) 364 | 365 | if len(aircraftToProcess) > 0 { 366 | MarkProcessed(pg, processedMetricName, aircraftToProcess) 367 | } 368 | } 369 | 370 | func getAircraftsForMeasurementStatistics(pg *postgres) []Aircraft { 371 | 372 | query := `SELECT id, hex, flight, r, t, first_seen, last_seen, alt_baro, alt_geom, gs, ias, tas, 373 | lowest_aircraft_processed, highest_aircraft_processed, fastest_aircraft_processed, slowest_aircraft_processed 374 | FROM aircraft_data 375 | WHERE lowest_aircraft_processed = false OR 376 | highest_aircraft_processed = false OR 377 | fastest_aircraft_processed = false OR 378 | slowest_aircraft_processed = false` 379 | 380 | rows, err := pg.db.Query(context.Background(), query) 381 | if err != nil { 382 | log.Error().Err(err).Msg("getAircraftsForMeasurementStatistics() - Error querying db") 383 | return nil 384 | } 385 | defer rows.Close() 386 | 387 | var aircrafts []Aircraft 388 | 389 | for rows.Next() { 390 | 391 | var aircraft Aircraft 392 | 393 | err := rows.Scan( 394 | &aircraft.Id, 395 | &aircraft.Hex, 396 | &aircraft.Flight, 397 | &aircraft.R, 398 | &aircraft.T, 399 | &aircraft.FirstSeen, 400 | &aircraft.LastSeen, 401 | &aircraft.AltBaro, 402 | &aircraft.AltGeom, 403 | &aircraft.Gs, 404 | &aircraft.Ias, 405 | &aircraft.Tas, 406 | &aircraft.LowestProcessed, 407 | &aircraft.HighestProcessed, 408 | &aircraft.FastestProcessed, 409 | &aircraft.SlowestProcessed) 410 | 411 | if err != nil { 412 | log.Error().Err(err).Msg("getAircraftsForMeasurementStatistics() - Error scanning rows") 413 | return nil 414 | } 415 | aircrafts = append(aircrafts, aircraft) 416 | } 417 | 418 | log.Debug().Msgf("Aircrafts that have not have statistics processed: %d", len(aircrafts)) 419 | return aircrafts 420 | } 421 | -------------------------------------------------------------------------------- /web/src/components/charts/FlightsByPeriod.svelte: -------------------------------------------------------------------------------- 1 | 315 | 316 | {#if loading} 317 |
318 | 319 |
320 | {:else if error} 321 |
322 | 323 | Something went wrong: {error} 324 |
325 | {:else if !chartData} 326 |
327 | 328 | No data available 329 |
330 | {:else} 331 |
332 | 333 |
334 | {/if} --------------------------------------------------------------------------------