mirror of
https://github.com/skidoodle/iphistory.git
synced 2026-04-27 23:37:35 +02:00
v2
This commit is contained in:
@@ -2,7 +2,7 @@ name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
branches: ["main"]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: sigstore/cosign-installer@v3.5.0
|
||||
with:
|
||||
cosign-release: 'v2.1.1'
|
||||
cosign-release: "v2.1.1"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
# This workflow will build a golang project
|
||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
|
||||
|
||||
name: Go
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
branches: ["main"]
|
||||
|
||||
jobs:
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -19,10 +15,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.22.3'
|
||||
go-version: "1.25.4"
|
||||
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
|
||||
- name: Test
|
||||
run: go test -v ./...
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
history*
|
||||
*templ.go
|
||||
+2
-1
@@ -1,12 +1,13 @@
|
||||
FROM golang:alpine as builder
|
||||
WORKDIR /build
|
||||
RUN go install github.com/a-h/templ/cmd/templ@latest
|
||||
COPY . .
|
||||
RUN templ generate
|
||||
RUN go build -o iphistory .
|
||||
|
||||
FROM alpine:latest
|
||||
RUN apk --no-cache add ca-certificates
|
||||
WORKDIR /app/
|
||||
COPY --from=builder /build/iphistory .
|
||||
COPY --from=builder /build/web ./web
|
||||
EXPOSE 8080
|
||||
CMD ["./iphistory"]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
services:
|
||||
iphistory:
|
||||
build: .
|
||||
container_name: iphistory
|
||||
ports:
|
||||
- "8080:8080"
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
iphistory:
|
||||
image: ghcr.io/skidoodle/iphistory:main
|
||||
@@ -8,8 +6,7 @@ services:
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- iphistory_data:/app
|
||||
- data:/app
|
||||
|
||||
volumes:
|
||||
iphistory_data:
|
||||
external: false
|
||||
data:
|
||||
@@ -1,60 +0,0 @@
|
||||
import random
|
||||
import json
|
||||
from datetime import datetime
|
||||
import os
|
||||
import sys
|
||||
|
||||
IP_HISTORY_FILE = 'history.json'
|
||||
|
||||
def generate_ipv4():
|
||||
return '.'.join(str(random.randint(0, 255)) for _ in range(4))
|
||||
|
||||
def generate_ipv6():
|
||||
return ':'.join('{:x}'.format(random.randint(0, 65535)) for _ in range(8))
|
||||
|
||||
def generate_timestamp():
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S %Z")
|
||||
|
||||
def generate_ip_record():
|
||||
return {
|
||||
"timestamp": generate_timestamp(),
|
||||
"ipv4": generate_ipv4(),
|
||||
"ipv6": generate_ipv6()
|
||||
}
|
||||
|
||||
def load_ip_history():
|
||||
if os.path.exists(IP_HISTORY_FILE):
|
||||
with open(IP_HISTORY_FILE, 'r') as file:
|
||||
try:
|
||||
return json.load(file)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return []
|
||||
|
||||
def save_ip_history(ip_history):
|
||||
with open(IP_HISTORY_FILE, 'w') as file:
|
||||
json.dump(ip_history, file, indent=2)
|
||||
|
||||
def generate_and_save_ip_records(num_records=1):
|
||||
ip_history = load_ip_history()
|
||||
new_records = [generate_ip_record() for _ in range(num_records)]
|
||||
ip_history = new_records + ip_history # Append new records at the beginning
|
||||
save_ip_history(ip_history)
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: python genhistory.py <number_of_records>")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
num_records = int(sys.argv[1])
|
||||
if num_records <= 0:
|
||||
raise ValueError("Number of records must be a positive integer.")
|
||||
|
||||
generate_and_save_ip_records(num_records)
|
||||
print(f"Successfully generated {num_records} IP records.")
|
||||
|
||||
except ValueError as e:
|
||||
print(f"Error: {e}")
|
||||
print("Please provide a valid positive integer for the number of records.")
|
||||
sys.exit(1)
|
||||
@@ -1,3 +1,22 @@
|
||||
module iphistory
|
||||
|
||||
go 1.22.3
|
||||
go 1.25.4
|
||||
|
||||
require (
|
||||
github.com/a-h/templ v0.3.977
|
||||
golang.org/x/sync v0.19.0
|
||||
modernc.org/sqlite v1.43.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
|
||||
github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.43.0 h1:8YqiFx3G1VhHTXO2Q00bl1Wz9KhS9Q5okwfp9Y97VnA=
|
||||
modernc.org/sqlite v1.43.0/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -1,195 +1,151 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
const (
|
||||
ipv4URL = "https://4.ident.me/"
|
||||
ipv6URL = "https://6.ident.me/"
|
||||
ipHistoryPath = "history.json"
|
||||
checkInterval = 30 * time.Second
|
||||
maxRetries = 3
|
||||
retryDelay = 10 * time.Second
|
||||
)
|
||||
//go:generate templ generate
|
||||
|
||||
type IPRecord struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
IPv4 string `json:"ipv4"`
|
||||
IPv6 string `json:"ipv6"`
|
||||
}
|
||||
func main() {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
|
||||
func getPublicIPs() (string, string, error) {
|
||||
var ipv4, ipv6 string
|
||||
var err error
|
||||
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
ipv4, ipv6, err = fetchIPs()
|
||||
if err == nil {
|
||||
return ipv4, ipv6, nil
|
||||
}
|
||||
fmt.Printf("Attempt %d: Error fetching IPs: %v\n", attempt, err)
|
||||
time.Sleep(retryDelay)
|
||||
}
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
func fetchIPs() (string, string, error) {
|
||||
ipv4, err := fetchIP(ipv4URL)
|
||||
store, err := NewStore("history.db")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
logger.Error("db init failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ipv6, err := fetchIP(ipv6URL)
|
||||
if err != nil {
|
||||
fmt.Println("Warning: Could not fetch IPv6 address:", err)
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
type provider struct {
|
||||
server string
|
||||
host string
|
||||
isTXT bool
|
||||
}
|
||||
|
||||
return ipv4, ipv6, nil
|
||||
}
|
||||
|
||||
func fetchIP(url string) (string, error) {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
providers := []provider{
|
||||
{server: "8.8.8.8:53", host: "o-o.myaddr.l.google.com", isTXT: true},
|
||||
{server: "208.67.222.222:53", host: "myip.opendns.com", isTXT: false},
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(body)), nil
|
||||
}
|
||||
|
||||
func readHistory() ([]IPRecord, error) {
|
||||
file, err := os.Open(ipHistoryPath)
|
||||
if os.IsNotExist(err) {
|
||||
return []IPRecord{}, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
fileInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
for {
|
||||
var detectedIP string
|
||||
for _, p := range providers {
|
||||
resolver := &net.Resolver{
|
||||
PreferGo: true,
|
||||
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
d := net.Dialer{Timeout: 5 * time.Second}
|
||||
return d.DialContext(ctx, "udp", p.server)
|
||||
},
|
||||
}
|
||||
|
||||
// Check if the file is empty
|
||||
if fileInfo.Size() == 0 {
|
||||
return []IPRecord{}, nil
|
||||
var raw string
|
||||
if p.isTXT {
|
||||
txt, err := resolver.LookupTXT(gCtx, p.host)
|
||||
if err == nil && len(txt) > 0 {
|
||||
raw = strings.Trim(txt[0], "\"")
|
||||
}
|
||||
} else {
|
||||
ips, err := resolver.LookupHost(gCtx, p.host)
|
||||
if err == nil && len(ips) > 0 {
|
||||
raw = ips[0]
|
||||
}
|
||||
}
|
||||
|
||||
var history []IPRecord
|
||||
err = json.NewDecoder(file).Decode(&history)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if ip := net.ParseIP(raw); ip != nil {
|
||||
detectedIP = ip.String()
|
||||
break
|
||||
}
|
||||
return history, nil
|
||||
}
|
||||
|
||||
func writeHistory(history []IPRecord) error {
|
||||
file, err := os.Create(ipHistoryPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
encoder := json.NewEncoder(file)
|
||||
encoder.SetIndent("", " ") // Pretty-print with indentation
|
||||
err = encoder.Encode(history)
|
||||
if err != nil {
|
||||
if detectedIP != "" {
|
||||
last, _ := store.GetLatest()
|
||||
if detectedIP != last {
|
||||
if err := store.Insert(detectedIP); err != nil {
|
||||
logger.Error("failed to save IP", "err", err)
|
||||
} else {
|
||||
logger.Info("IP change detected", "ip", detectedIP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-gCtx.Done():
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /{$}", handleList(store, logger))
|
||||
mux.HandleFunc("GET /p/{page}", handleList(store, logger))
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":8080",
|
||||
Handler: mux,
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
g.Go(func() error {
|
||||
logger.Info("server started", "url", "http://localhost:8080")
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func trackIP(ipChan <-chan IPRecord) {
|
||||
var lastLoggedIP IPRecord
|
||||
hasNotifiedUpToDate := false
|
||||
|
||||
for ipRecord := range ipChan {
|
||||
history, err := readHistory()
|
||||
if err != nil {
|
||||
fmt.Println("Error reading IP history:", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(history) > 0 {
|
||||
lastLoggedIP = history[0]
|
||||
}
|
||||
|
||||
// Check if IPs are the same as the last logged one
|
||||
if ipRecord.IPv4 == lastLoggedIP.IPv4 && ipRecord.IPv6 == lastLoggedIP.IPv6 {
|
||||
if !hasNotifiedUpToDate {
|
||||
fmt.Println("🤷 IPs are already up to date")
|
||||
hasNotifiedUpToDate = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Reset the notification flag when IP changes
|
||||
hasNotifiedUpToDate = false
|
||||
|
||||
ipRecord.Timestamp = time.Now().Format("2006-01-02 15:04:05")
|
||||
history = append([]IPRecord{ipRecord}, history...)
|
||||
if err := writeHistory(history); err != nil {
|
||||
fmt.Println("Error writing IP history:", err)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("📄 New IP logged: {Timestamp: %s, IPv4: %s, IPv6: %s}\n", ipRecord.Timestamp, ipRecord.IPv4, ipRecord.IPv6)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func serveWeb() {
|
||||
http.Handle("/", http.StripPrefix("/", http.FileServer(http.Dir("web"))))
|
||||
|
||||
http.HandleFunc("/history", func(w http.ResponseWriter, r *http.Request) {
|
||||
history, err := readHistory()
|
||||
if err != nil {
|
||||
http.Error(w, "Error reading IP history", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(history)
|
||||
if err != nil {
|
||||
http.Error(w, "Error converting history to JSON", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(jsonData)
|
||||
})
|
||||
|
||||
fmt.Println("Starting server on :8080")
|
||||
http.ListenAndServe(":8080", nil)
|
||||
g.Go(func() error {
|
||||
<-gCtx.Done()
|
||||
sCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return srv.Shutdown(sCtx)
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil && !errors.Is(err, context.Canceled) {
|
||||
logger.Error("application error", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
ipChan := make(chan IPRecord)
|
||||
func handleList(store *Store, logger *slog.Logger) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
page, _ := strconv.Atoi(r.PathValue("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
query := r.URL.Query().Get("q")
|
||||
|
||||
go func() {
|
||||
for {
|
||||
ipv4, ipv6, err := getPublicIPs()
|
||||
records, hasMore, err := store.FetchPage(query, page, 50)
|
||||
if err != nil {
|
||||
fmt.Println("Error fetching public IPs:", err)
|
||||
} else {
|
||||
ipChan <- IPRecord{IPv4: ipv4, IPv6: ipv6}
|
||||
slog.Error("DB Error", "err", err)
|
||||
http.Error(w, "Internal Error", 500)
|
||||
return
|
||||
}
|
||||
time.Sleep(checkInterval)
|
||||
}
|
||||
}()
|
||||
|
||||
go trackIP(ipChan)
|
||||
serveWeb()
|
||||
if r.Header.Get("HX-Request") == "true" && r.Header.Get("HX-Target") == "main-content" {
|
||||
_ = MainContent(records, query, page, hasMore).Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
_ = Page(records, query, page, hasMore).Render(r.Context(), w)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +1,22 @@
|
||||
# IPHistory
|
||||
|
||||
The IPHistory project is a simple yet effective solution for tracking and logging the public IP address of your network. It periodically fetches the public IP address and logs it to a file, while also providing a web interface to view the IP history in a clean UI and an endpoint (`/history`) for JSON format.
|
||||
A simple tool for tracking your network's public IP address. It periodically checks for changes and logs them to a local database, providing a clean, searchable web interface to browse your history. It's designed to be lightweight, self-reliant, and fast without requiring any maintenance.
|
||||
|
||||

|
||||
|
||||
## Running Locally
|
||||
|
||||
### With Docker
|
||||
|
||||
```sh
|
||||
git clone https://github.com/skidoodle/iphistory
|
||||
cd iphistory
|
||||
docker build -t iphistory:main .
|
||||
docker run -p 8080:8080 iphistory:main
|
||||
```
|
||||
|
||||
### Without Docker
|
||||
|
||||
```sh
|
||||
git clone https://github.com/skidoodle/iphistory
|
||||
cd iphistory
|
||||
go run main.go
|
||||
```
|
||||
|
||||
## Deploying
|
||||
|
||||
### Docker Compose
|
||||
## Deploy
|
||||
|
||||
```yaml
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
iphistory:
|
||||
image: ghcr.io/skidoodle/iphistory:main
|
||||
image: ghcr.io/skidoodle/iphistory
|
||||
container_name: iphistory
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- iphistory_data:/app
|
||||
- data:/app
|
||||
|
||||
volumes:
|
||||
iphistory_data:
|
||||
external: false
|
||||
```
|
||||
|
||||
### Docker Run
|
||||
|
||||
```sh
|
||||
docker run \
|
||||
-d \
|
||||
--name=iphistory \
|
||||
--restart=unless-stopped \
|
||||
-p 8080:8080 \
|
||||
ghcr.io/skidoodle/iphistory:main
|
||||
data:
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
panic("usage: go run seed.go <count>")
|
||||
}
|
||||
|
||||
n, _ := strconv.Atoi(os.Args[1])
|
||||
store, err := NewStore("history.db")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
store.db.Exec("PRAGMA synchronous = OFF")
|
||||
store.db.Exec("PRAGMA journal_mode = MEMORY")
|
||||
|
||||
tx, _ := store.db.Begin()
|
||||
|
||||
batchSize := 1000
|
||||
q := strings.Builder{}
|
||||
vals := make([]any, 0, batchSize*2)
|
||||
baseTime := time.Now().UTC()
|
||||
|
||||
fmt.Printf("generating %d rows...\n", n)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
ip := strconv.Itoa(rand.IntN(254)+1) + "." +
|
||||
strconv.Itoa(rand.IntN(255)) + "." +
|
||||
strconv.Itoa(rand.IntN(255)) + "." +
|
||||
strconv.Itoa(rand.IntN(255))
|
||||
|
||||
ts := baseTime.Add(time.Duration(-i) * time.Hour)
|
||||
|
||||
if len(vals) == 0 {
|
||||
q.WriteString("INSERT INTO ip_history (ip, ts) VALUES ")
|
||||
} else {
|
||||
q.WriteString(",")
|
||||
}
|
||||
q.WriteString("(?,?)")
|
||||
vals = append(vals, ip, ts)
|
||||
|
||||
if len(vals) >= batchSize*2 {
|
||||
if _, err := tx.Exec(q.String(), vals...); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
q.Reset()
|
||||
vals = vals[:0]
|
||||
}
|
||||
}
|
||||
|
||||
if len(vals) > 0 {
|
||||
tx.Exec(q.String(), vals...)
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
fmt.Println("done")
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type Record struct {
|
||||
ID int
|
||||
Timestamp time.Time
|
||||
IP string
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewStore(path string) (*Store, error) {
|
||||
dsn := fmt.Sprintf("%s?_pragma=journal_mode(WAL)&_pragma=synchronous=NORMAL&_pragma=mmap_size=268435456", path)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS ip_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
ip TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ts ON ip_history(ts DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_ip ON ip_history(ip COLLATE NOCASE);`
|
||||
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetLatest() (string, error) {
|
||||
var ip string
|
||||
err := s.db.QueryRow("SELECT ip FROM ip_history ORDER BY ts DESC LIMIT 1").Scan(&ip)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
return ip, err
|
||||
}
|
||||
|
||||
func (s *Store) Insert(ip string) error {
|
||||
_, err := s.db.Exec("INSERT INTO ip_history (ip, ts) VALUES (?, ?)", ip, time.Now().UTC())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) FetchPage(query string, page, pageSize int) ([]Record, bool, error) {
|
||||
offset := (page - 1) * pageSize
|
||||
limit := pageSize + 1
|
||||
|
||||
q := query + "%"
|
||||
if query == "" {
|
||||
q = "%"
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(
|
||||
"SELECT id, ts, ip FROM ip_history WHERE ip LIKE ? ORDER BY ts DESC LIMIT ? OFFSET ?",
|
||||
q, limit, offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []Record
|
||||
for rows.Next() {
|
||||
var r Record
|
||||
if err := rows.Scan(&r.ID, &r.Timestamp, &r.IP); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
records = append(records, r)
|
||||
}
|
||||
|
||||
hasMore := len(records) > pageSize
|
||||
if hasMore {
|
||||
records = records[:pageSize]
|
||||
}
|
||||
return records, hasMore, nil
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func pathBuilder(page int, query string) string {
|
||||
base := "/"
|
||||
if page > 1 {
|
||||
base = fmt.Sprintf("/p/%d", page)
|
||||
}
|
||||
if query != "" {
|
||||
return fmt.Sprintf("%s?q=%s", base, query)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
templ MainContent(records []Record, query string, page int, hasMore bool) {
|
||||
<div id="main-content" class="fade-in">
|
||||
if page == 1 && query == "" && len(records) > 0 {
|
||||
<section class="current-ip-card">
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted); margin-bottom: 0.5rem;">Current Public IP</div>
|
||||
<div class="ip-display">{ records[0].IP }</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 0.5rem;">
|
||||
Last observed: { records[0].Timestamp.Format("Jan 02, 15:04:05 MST") }
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
<table class="history-table">
|
||||
<thead>
|
||||
<tr><th>Timestamp</th><th>Observed IP</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, r := range records {
|
||||
<tr>
|
||||
<td style="color: var(--text-muted);">{ r.Timestamp.Format("2006-01-02 15:04:05") }</td>
|
||||
<td style="font-family: ui-monospace, monospace; font-weight: 500;">{ r.IP }</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pagination">
|
||||
if page > 1 {
|
||||
<a
|
||||
href={ templ.SafeURL(pathBuilder(page-1, query)) }
|
||||
hx-get={ pathBuilder(page-1, query) }
|
||||
hx-target="#main-content"
|
||||
hx-push-url="true"
|
||||
class="nav-link"
|
||||
>← Previous</a>
|
||||
} else {
|
||||
<a class="nav-link disabled">← Previous</a>
|
||||
}
|
||||
<span style="font-size: 0.875rem; color: var(--text-muted);">Page { fmt.Sprint(page) }</span>
|
||||
if hasMore {
|
||||
<a
|
||||
href={ templ.SafeURL(pathBuilder(page+1, query)) }
|
||||
hx-get={ pathBuilder(page+1, query) }
|
||||
hx-target="#main-content"
|
||||
hx-push-url="true"
|
||||
class="nav-link"
|
||||
>Next →</a>
|
||||
} else {
|
||||
<a class="nav-link disabled">Next →</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ Page(records []Record, query string, page int, hasMore bool) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>IP History</title>
|
||||
<link rel="icon" href="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"/>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<style>
|
||||
:root { --bg: #0a0a0c; --card: #16161a; --border: #2d2d33; --text: #ececed; --text-muted: #94949e; --primary: #3b82f6; }
|
||||
html { scrollbar-gutter: stable; overflow-y: auto; }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: ui-sans-serif, system-ui, sans-serif; background-color: var(--bg); color: var(--text); line-height: 1.6; padding: 2rem 1rem; }
|
||||
.container { max-width: 800px; margin-inline: auto; }
|
||||
header { margin-bottom: 2.5rem; }
|
||||
h1 { font-size: 1.5rem; font-weight: 600; letter-spacing: -0.025em; }
|
||||
a.title-link { text-decoration: none; color: inherit; display: inline-block; }
|
||||
a.title-link:hover h1 { color: var(--primary); }
|
||||
.search-form { margin-bottom: 2rem; display: flex; gap: 0.5rem; }
|
||||
.search-input { flex: 1; background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 0.75rem 1rem; color: var(--text); font-size: 1rem; }
|
||||
.search-input:focus { outline: none; border-color: var(--primary); }
|
||||
.btn-search { background: var(--primary); color: white; border: none; padding: 0 1.5rem; border-radius: 8px; font-weight: 600; cursor: pointer; }
|
||||
.current-ip-card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 1.5rem; margin-bottom: 2rem; }
|
||||
.ip-display { font-family: ui-monospace, monospace; font-size: 2rem; font-weight: 700; color: var(--primary); }
|
||||
.history-table { width: 100%; border-collapse: separate; border-spacing: 0; background: var(--card); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
||||
th, td { padding: 1rem; text-align: left; border-bottom: 1px solid var(--border); }
|
||||
th { font-size: 0.75rem; text-transform: uppercase; color: var(--text-muted); background: rgba(255,255,255,0.02); }
|
||||
.pagination { display: flex; justify-content: space-between; align-items: center; margin-top: 1.5rem; }
|
||||
.nav-link { background: var(--card); border: 1px solid var(--border); color: var(--text); padding: 0.5rem 1rem; border-radius: 6px; text-decoration: none; font-size: 0.875rem; }
|
||||
.nav-link:hover { border-color: var(--primary); }
|
||||
.nav-link.disabled { opacity: 0.2; pointer-events: none; }
|
||||
.fade-in { animation: fadeIn 0.15s ease-out; }
|
||||
@keyframes fadeIn { from { opacity: 0.5; transform: translateY(2px); } to { opacity: 1; transform: translateY(0); } }
|
||||
</style>
|
||||
</head>
|
||||
<body hx-boost="true">
|
||||
<main class="container">
|
||||
<header>
|
||||
<a href="/" hx-boost="false" class="title-link"><h1>IP History</h1></a>
|
||||
<p style="color: var(--text-muted); font-size: 0.875rem;">A simple timeline of your public IP changes</p>
|
||||
</header>
|
||||
<form class="search-form" hx-get="/" hx-target="#main-content" hx-push-url="true">
|
||||
<input type="text" name="q" class="search-input" placeholder="Search..." value={ query } autocomplete="off"/>
|
||||
<button type="submit" class="btn-search">Search</button>
|
||||
</form>
|
||||
@MainContent(records, query, page, hasMore)
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<title>IP History</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script defer src="script.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<h1><a href="/">IP History</a></h1>
|
||||
<div class="controls">
|
||||
<button class="export" id="exportBtn">Export to CSV</button>
|
||||
<input type="text" id="searchBar" class="search-bar" placeholder="Search IP history..." autocomplete="off">
|
||||
</div>
|
||||
<table id="ipTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th id="sortTimestamp">Timestamp</th>
|
||||
<th id="sortIPv4">IPv4 Address</th>
|
||||
<th id="sortIPv6">IPv6 Address</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div class="pagination" id="pagination">
|
||||
<button id="prevBtn" disabled>« Prev</button>
|
||||
<button id="nextBtn" disabled>Next »</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
-115
@@ -1,115 +0,0 @@
|
||||
let ipHistory = [];
|
||||
let filteredHistory = [];
|
||||
let currentPage = 1;
|
||||
let entriesPerPage = 10;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
fetch('/history')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
ipHistory = data;
|
||||
filteredHistory = ipHistory;
|
||||
displayTable();
|
||||
updateTotalIPs();
|
||||
})
|
||||
.catch(error => console.error('Error fetching IP history:', error));
|
||||
|
||||
const searchBar = document.getElementById('searchBar');
|
||||
searchBar.addEventListener('input', handleSearch);
|
||||
|
||||
document.getElementById('prevBtn').addEventListener('click', prevPage);
|
||||
document.getElementById('nextBtn').addEventListener('click', nextPage);
|
||||
document.getElementById('exportBtn').addEventListener('click', exportToCSV);
|
||||
document.getElementById('sortTimestamp').addEventListener('click', () => sortTable('timestamp'));
|
||||
document.getElementById('sortIPv4').addEventListener('click', () => sortTable('ipv4'));
|
||||
document.getElementById('sortIPv6').addEventListener('click', () => sortTable('ipv6'));
|
||||
});
|
||||
|
||||
|
||||
function handleSearch() {
|
||||
const query = this.value.toLowerCase();
|
||||
filteredHistory = ipHistory.filter(entry =>
|
||||
entry.timestamp.toLowerCase().includes(query) ||
|
||||
(entry.ipv4 && entry.ipv4.toLowerCase().includes(query)) ||
|
||||
(entry.ipv6 && entry.ipv6.toLowerCase().includes(query))
|
||||
);
|
||||
currentPage = 1;
|
||||
displayTable();
|
||||
updateTotalIPs();
|
||||
}
|
||||
|
||||
function displayTable() {
|
||||
const tbody = document.querySelector('#ipTable tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
const start = (currentPage - 1) * entriesPerPage;
|
||||
const end = start + entriesPerPage;
|
||||
const currentEntries = filteredHistory.slice(start, end);
|
||||
|
||||
currentEntries.forEach(entry => {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td>${entry.timestamp}</td>
|
||||
<td>${entry.ipv4 || 'N/A'}</td>
|
||||
<td>${entry.ipv6 || 'N/A'}</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
updatePaginationButtons();
|
||||
updateTotalIPs();
|
||||
}
|
||||
|
||||
function updateTotalIPs() {
|
||||
const totalIPs = filteredHistory.length;
|
||||
const searchBar = document.getElementById('searchBar');
|
||||
searchBar.placeholder = `Search IP history... (Total IPs: ${totalIPs})`;
|
||||
}
|
||||
|
||||
function updatePaginationButtons() {
|
||||
const prevBtn = document.getElementById('prevBtn');
|
||||
const nextBtn = document.getElementById('nextBtn');
|
||||
prevBtn.disabled = currentPage === 1;
|
||||
nextBtn.disabled = currentPage === Math.ceil(filteredHistory.length / entriesPerPage);
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (currentPage < Math.ceil(filteredHistory.length / entriesPerPage)) {
|
||||
currentPage++;
|
||||
displayTable();
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage > 1) {
|
||||
currentPage--;
|
||||
displayTable();
|
||||
}
|
||||
}
|
||||
|
||||
function sortTable(field) {
|
||||
const isAscending = document.getElementById(`sort${capitalizeFirstLetter(field)}`).classList.contains('sort-asc');
|
||||
filteredHistory.sort((a, b) => (a[field] && b[field]) ? a[field].localeCompare(b[field]) * (isAscending ? 1 : -1) : 0);
|
||||
|
||||
// Toggle the sort direction class
|
||||
document.querySelectorAll('th').forEach(th => th.classList.remove('sort-asc', 'sort-desc'));
|
||||
document.getElementById(`sort${capitalizeFirstLetter(field)}`).classList.add(isAscending ? 'sort-desc' : 'sort-asc');
|
||||
|
||||
displayTable();
|
||||
}
|
||||
|
||||
function exportToCSV() {
|
||||
const rows = [
|
||||
['Timestamp', 'IPv4 Address', 'IPv6 Address'],
|
||||
...filteredHistory.map(entry => [entry.timestamp, entry.ipv4 || '', entry.ipv6 || ''])
|
||||
];
|
||||
const csvContent = rows.map(row => row.join(',')).join('\n');
|
||||
const blob = new Blob([csvContent], { type: 'text/csv' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = 'ip_history.csv';
|
||||
link.click();
|
||||
}
|
||||
|
||||
function capitalizeFirstLetter(string) {
|
||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||
}
|
||||
-221
@@ -1,221 +0,0 @@
|
||||
/* General */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: 'Poppins', Arial, sans-serif;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #121212;
|
||||
color: #e0e0e0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 20px;
|
||||
color: #007bff;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h1 a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
h1 a:hover {
|
||||
color: #0056b3;
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.controls {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.export {
|
||||
background-color: #007bff;
|
||||
border: 1px solid #007bff;
|
||||
color: #fff;
|
||||
padding: 12px 24px;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
flex: 0 1 30%;
|
||||
text-align: center;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.export:hover {
|
||||
background-color: #0056b3;
|
||||
border-color: #0056b3;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
flex: 0 1 65%;
|
||||
padding: 12px;
|
||||
font-size: 16px;
|
||||
border-radius: 5px;
|
||||
background-color: #333;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
||||
.search-bar:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
table {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
border-collapse: collapse;
|
||||
margin: 20px auto;
|
||||
background-color: #1e1e1e;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 16px 20px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #333;
|
||||
font-size: 16px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #007bff;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
th.sort-asc::after,
|
||||
th.sort-desc::after {
|
||||
content: " ▼";
|
||||
font-size: 12px;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
th.sort-asc::after {
|
||||
content: " ▲";
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background-color: #292929;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: #333;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
background-color: #007bff;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
margin: 0 5px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
background-color: #555;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.export, .search-bar {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.export, .search-bar {
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
font-size: 12px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user