├── 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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
10 |
14 |
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 |
19 |
22 |
25 |
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 |
--------------------------------------------------------------------------------
/web/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/web/src/components/ActivityFlightsByPeriod.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
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 |
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 |
24 |
34 |
35 |
37 |
38 |
39 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/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 |
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 |
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 |
63 |
Flights with routes
64 |
65 |
all time
66 |
67 |
68 |
73 |
Unique Countries
74 |
75 |
all time
76 |
77 |
78 |
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 |
62 |
Flights seen
63 |
64 |
past hour
65 |
66 |
67 |
72 |
Flights seen
73 |
74 |
today
75 |
76 |
77 |
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 |
63 |
Aircraft seen
64 |
65 |
past hour
66 |
67 |
68 |
73 |
Aircraft seen
74 |
75 |
today
76 |
77 |
78 |
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 |
64 |
Interesting flights seen
65 |
66 |
past hour
67 |
68 |
69 |
74 |
Interesting flights seen
75 |
76 |
today
77 |
78 |
79 |
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 |
78 |
79 |
80 |
81 |
82 |
83 | {#each columns as column}
84 | {column.header}
85 | {/each}
86 |
87 |
88 |
89 | {#each data as aircraft}
90 |
91 | {#each columns as column}
92 |
93 | {#if column.formatter}
94 | {@html column.formatter(aircraft[column.field])}
95 | {:else}
96 | {aircraft[column.field] || '-'}
97 | {/if}
98 |
99 | {/each}
100 |
101 | {/each}
102 |
103 |
104 | {/if}
105 |
106 |
107 |
108 |
--------------------------------------------------------------------------------
/web/src/App.svelte:
--------------------------------------------------------------------------------
1 |
36 |
37 |
38 |
39 |
40 |
51 |
52 |
53 |
54 | Skystats
55 |
56 |
57 |
58 |
59 |
60 |
61 |
openSettingsModal()}
64 | >
65 |
66 |
67 |
76 |
77 |
78 |
79 |
80 |
83 |
84 |
85 |
86 | {#each tabs as tab}
87 | setActiveTab(tab.name)}>
93 | {tab.label}
94 |
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 |
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 | 
132 |
133 | 
134 |
135 |
136 | ### Route Stats
137 | 
138 |
139 |
140 | ### Interesting Aircraft
141 | 
142 |
143 | 
144 |
145 |
146 | ### Motion Stats
147 | 
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 |
101 |
102 |
103 |
104 |
105 |
106 | Reg
107 | Operator
108 | Type
109 | Last Seen
110 |
111 |
112 |
113 | {#each data as aircraft}
114 | showAircraftModal(aircraft)}>
115 | {aircraft.registration}
116 | {aircraft.operator}
117 | {aircraft.type}
118 | {aircraft.seen ? new Date(aircraft.seen).toLocaleString() : '-'}
119 |
120 | {/each}
121 |
122 |
123 | {/if}
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 | {#if selectedAircraft}
133 |
134 |
{selectedAircraft.registration} - {selectedAircraft.type}
135 | {#if disableTags === false}
136 |
137 | {#if selectedAircraft.tag1}
138 |
{selectedAircraft.tag1}
139 | {/if}
140 | {#if selectedAircraft.tag2}
141 |
{selectedAircraft.tag2}
142 | {/if}
143 | {#if selectedAircraft.tag3}
144 |
{selectedAircraft.tag3}
145 | {/if}
146 |
147 | {/if}
148 |
149 |
{selectedAircraft.operator} {#if selectedAircraft.flight} - {selectedAircraft.flight} {/if}
150 |
151 | {#if selectedAircraft.image_link_1}
152 |
153 | {#if imageLoadingStates.image1}
154 |
155 | {/if}
156 |
imageLoadingStates.image1 = false}
161 | on:error={() => imageLoadingStates.image1 = false}
162 | />
163 |
164 | {/if}
165 | {#if selectedAircraft.image_link_2}
166 |
167 | {#if imageLoadingStates.image2}
168 |
169 | {/if}
170 |
imageLoadingStates.image2 = false}
175 | on:error={() => imageLoadingStates.image2 = false}
176 | />
177 |
178 | {/if}
179 | {#if selectedAircraft.image_link_3}
180 |
181 | {#if imageLoadingStates.image3}
182 |
183 | {/if}
184 |
imageLoadingStates.image3 = false}
189 | on:error={() => imageLoadingStates.image3 = false}
190 | />
191 |
192 | {/if}
193 |
194 | {#if !selectedAircraft.image_link_1 && !selectedAircraft.image_link_2 && !selectedAircraft.image_link_3}
195 |
No photos available for this aircraft
196 | {/if}
197 | {/if}
198 |
199 |
202 |
203 |
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}
--------------------------------------------------------------------------------