Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions frontend/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})
build: {
rollupOptions: {
output: {
// Stable vendor chunk so repeat loads hit cache even when app code changes.
codeSplitting: {
groups: [
{
name: "vendor",
test: /[\\/]node_modules[\\/](react|react-dom|scheduler|axios)[\\/]/,
},
],
},
},
},
},
});
13 changes: 12 additions & 1 deletion internal/bootstrap/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package bootstrap
import (
"fmt"
"log"
"strings"

"github.com/DarknessKiller/pingopher/internal/config"
"github.com/DarknessKiller/pingopher/internal/model"
Expand Down Expand Up @@ -41,7 +42,7 @@ func InitiateDatabase(cfg *config.Config) *gorm.DB {

func InitiateSQLiteDatabase(cfg *config.Config) *gorm.DB {

db, err := gorm.Open(sqlite.Open(cfg.SQLitePath), &gorm.Config{})
db, err := gorm.Open(sqlite.Open(sqliteDSN(cfg.SQLitePath)), &gorm.Config{})
if err != nil {
log.Fatal("Failed to connect to SQLite:", err)
}
Expand All @@ -64,3 +65,13 @@ func InitiateD1Database(cfg *config.Config) *gorm.DB {
log.Println("D1 connected! Database:", cfg.CloudflareD1.DatabaseString)
return gormDB
}

// sqliteDSN appends the durability pragmas used for concurrent probe writes: WAL lets readers work
// during a write, NORMAL sync keeps commits cheap. busy_timeout(5000) is added by the driver itself.
func sqliteDSN(path string) string {
sep := "?"
if strings.Contains(path, "?") {
sep = "&"
}
return path + sep + "_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)"
}
18 changes: 18 additions & 0 deletions internal/bootstrap/database_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package bootstrap_test

import (
"testing"

"github.com/DarknessKiller/pingopher/internal/bootstrap"
)

func TestSQLiteDSNPragmas(t *testing.T) {
got := bootstrap.SQLiteDSN("pingopher.db")
want := "pingopher.db?_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
if bootstrap.SQLiteDSN("file:x?y") != "file:x?y&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)" {
t.Error("existing query separator must survive")
}
}
3 changes: 3 additions & 0 deletions internal/bootstrap/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package bootstrap

var SQLiteDSN = sqliteDSN
5 changes: 5 additions & 0 deletions internal/service/uptime/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"net"

"github.com/DarknessKiller/pingopher/internal/model"
"resty.dev/v3"
)

type (
Expand All @@ -16,6 +17,10 @@ var (
NewDNSCache = newDNSCache
ParseResponse = parseResponse
QueryServer = queryServer
// ClientForHTTP and InitHTTPClients expose the shared-client pool to tests without an export
// surface on production code.
ClientForHTTP = (*Service).httpClient
InitHTTPClients = func(s *Service) { s.httpClients = make(map[httpClientKey]*resty.Client) }
)

func Lookup(cache *DNSCache, ctx context.Context, dns model.DNS, host string, resolve func(context.Context, string) ([]DNSRecord, error)) ([]net.IPAddr, error) {
Expand Down
70 changes: 47 additions & 23 deletions internal/service/uptime/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,28 @@ type Service struct {
repository Repository
cache cache.Cache
dnsCache *dnsCache

httpMu sync.Mutex
// Shared clients keep TCP/TLS connections alive across probes; a per-probe client costs a full handshake.
httpClients map[httpClientKey]*resty.Client
}

// httpClientKey separates clients by pinned resolver (per-DNS dialer) and TLS verify mode, the two
// per-host settings endpoints share one pool.
type httpClientKey struct {
dns model.DNS
insecure bool
}

func NewService(config *config.Config, hostRepository repository.HostRepository, historyRepository repository.HistoryRepository, cacheClient cache.Cache) *Service {
return &Service{
config: config,
repository: &newRepository{HostRepo: hostRepository, HistoryRepo: historyRepository},
cache: cacheClient,
dnsCache: newDNSCache(),
config: config,
repository: &newRepository{HostRepo: hostRepository, HistoryRepo: historyRepository},
cache: cacheClient,
dnsCache: newDNSCache(),
httpClients: make(map[httpClientKey]*resty.Client),
}

}

func (s *Service) CreateHost(ctx context.Context, host *model.Host) error {
Expand Down Expand Up @@ -224,29 +237,12 @@ func historyIsDown(host *model.Host, history *model.History) (bool, error) {
}

func (s *Service) pingHTTP(ctx context.Context, host *model.Host, dns model.DNS) *model.History {
client := resty.New().SetTimeout(pingTimeout)

// Dial through the shared DNS cache so the transport does not re-resolve per check.
if transport, err := client.HTTPTransport(); err == nil {
transport.DialContext = s.getDialer(dns).DialContext
client.SetTransport(transport)
}

userAgent := "Pingopher/Alpha (https://github.com/DarknessKiller/pingopher)"
client.SetHeader("User-Agent", userAgent).
SetDebug(s.config.Env != "production")

defer client.Close()

insecure := host.TLS.NoVerify != nil && *host.TLS.NoVerify
client := s.httpClient(dns, insecure)
hostURL := strings.ToLower(host.Protocol) + "://" + host.HostURL
if host.Port != nil && *host.Port != 0 {
hostURL = strings.ToLower(host.Protocol) + "://" + net.JoinHostPort(host.HostURL, strconv.Itoa(int(*host.Port)))
}

if host.TLS.NoVerify != nil && *host.TLS.NoVerify {
client.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
}

var errorMsg string
resp, err := client.R().WithContext(ctx).Get(hostURL)
switch err.(type) {
Expand Down Expand Up @@ -274,6 +270,34 @@ func (s *Service) pingHTTP(ctx context.Context, host *model.Host, dns model.DNS)
}
}

// httpClient returns a long-lived client for a (resolver, TLS verify) pair. First call wires the
// per-DNS dialer and TLS mode; later probes reuse the pooled connections without re-resolving.
func (s *Service) httpClient(dns model.DNS, insecure bool) *resty.Client {
s.httpMu.Lock()
defer s.httpMu.Unlock()

key := httpClientKey{dns: dns, insecure: insecure}
if client, ok := s.httpClients[key]; ok {
return client
}

client := resty.New().SetTimeout(pingTimeout)
if transport, err := client.HTTPTransport(); err == nil {
transport.DialContext = s.getDialer(dns).DialContext
client.SetTransport(transport)
}
if insecure {
client.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true})
}
client.SetHeader("User-Agent", "Pingopher/Alpha (https://github.com/DarknessKiller/pingopher)").
SetDebug(s.config != nil && s.config.Env != "production")

s.httpClients[key] = client
return client
}



func (s *Service) pingTCP(ctx context.Context, host *model.Host, dns model.DNS) *model.History {
target := targetAddr(host)

Expand Down
18 changes: 18 additions & 0 deletions internal/service/uptime/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -328,3 +328,21 @@ func TestService_PingHost(t *testing.T) {
}
})
}

func TestService_HTTPClientReuse(t *testing.T) {
sys := model.DNS{Name: "System DNS"}
other := model.DNS{Name: "1.1.1.1"}

svc := &uptime.Service{}
uptime.InitHTTPClients(svc)
first := uptime.ClientForHTTP(svc, sys, false)
if uptime.ClientForHTTP(svc, sys, false) != first {
t.Error("same dns+secure should return the same client")
}
if uptime.ClientForHTTP(svc, sys, true) == first {
t.Error("insecure variant must be a separate client")
}
if uptime.ClientForHTTP(svc, other, false) == first {
t.Error("other resolver must be a separate client")
}
}
Loading