Skip to content
Open
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
6 changes: 3 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ build: frontend backend

# Build frontend
frontend:
cd frontend && npm install && npm run build
cd frontend && pnpm install && pnpm run build

# Build backend (includes embedded frontend)
backend:
Expand Down Expand Up @@ -41,12 +41,12 @@ test:
# Format code
fmt:
go fmt ./...
cd frontend && npm run format 2>/dev/null || true
cd frontend && pnpm run format 2>/dev/null || true

# Tidy dependencies
tidy:
go mod tidy
cd frontend && npm install
cd frontend && pnpm install

# Build Docker image
image: build
Expand Down
54 changes: 50 additions & 4 deletions api/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"supafirehose/load"
"supafirehose/metrics"
"supafirehose/schema"
)

// Handlers holds the HTTP handler dependencies
Expand Down Expand Up @@ -47,10 +48,12 @@ func (h *Handlers) HandleStatus(w http.ResponseWriter, r *http.Request) {

// ConfigRequest is the request body for POST /api/config
type ConfigRequest struct {
Connections int `json:"connections"`
ReadQPS int `json:"read_qps"`
WriteQPS int `json:"write_qps"`
ChurnRate int `json:"churn_rate"`
Connections int `json:"connections"`
ReadQPS int `json:"read_qps"`
WriteQPS int `json:"write_qps"`
ChurnRate int `json:"churn_rate"`
Scenario string `json:"scenario,omitempty"`
CustomTable string `json:"custom_table,omitempty"`
}

// ConfigResponse is the response for POST /api/config
Expand All @@ -72,11 +75,26 @@ func (h *Handlers) HandleConfig(w http.ResponseWriter, r *http.Request) {
return
}

// Get current config for defaults
currentConfig := h.controller.GetConfig()

// Use current values if not provided
scenario := req.Scenario
if scenario == "" {
scenario = currentConfig.Scenario
}
customTable := req.CustomTable
if customTable == "" && scenario == currentConfig.Scenario {
customTable = currentConfig.CustomTable
}

cfg := load.Config{
Connections: req.Connections,
ReadQPS: req.ReadQPS,
WriteQPS: req.WriteQPS,
ChurnRate: req.ChurnRate,
Scenario: scenario,
CustomTable: customTable,
}

h.controller.UpdateConfig(cfg)
Expand Down Expand Up @@ -150,3 +168,31 @@ func writeJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}

// ScenariosResponse is the response for GET /api/scenarios
type ScenariosResponse struct {
Scenarios []schema.ScenarioInfo `json:"scenarios"`
}

// HandleScenarios returns the list of available scenarios
func (h *Handlers) HandleScenarios(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}

scenarios := h.controller.GetRegistry().List()

// Add custom scenario option
scenarios = append(scenarios, schema.ScenarioInfo{
Name: "custom",
Description: "Custom table (optionally specify table name)",
TableName: "",
})

resp := ScenariosResponse{
Scenarios: scenarios,
}

writeJSON(w, resp)
}
1 change: 1 addition & 0 deletions api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ func NewRouter(handlers *Handlers, wsHub *WebSocketHub, staticFS fs.FS) http.Han
mux.HandleFunc("/api/start", handlers.HandleStart)
mux.HandleFunc("/api/stop", handlers.HandleStop)
mux.HandleFunc("/api/reset", handlers.HandleReset)
mux.HandleFunc("/api/scenarios", handlers.HandleScenarios)

// WebSocket route
mux.HandleFunc("/ws/metrics", wsHub.HandleWebSocket)
Expand Down
8 changes: 6 additions & 2 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ type Config struct {

// Metrics
MetricsInterval time.Duration
MaxUserID int64

// Schema scenario
DefaultScenario string
CustomTable string
}

// Load reads configuration from environment variables with defaults
Expand All @@ -41,7 +44,8 @@ func Load() *Config {
MaxReadQPS: getEnvInt("MAX_READ_QPS", 500000),
MaxWriteQPS: getEnvInt("MAX_WRITE_QPS", 500000),
MetricsInterval: getEnvDuration("METRICS_INTERVAL", 100*time.Millisecond),
MaxUserID: getEnvInt64("MAX_USER_ID", 100000),
DefaultScenario: getEnv("DEFAULT_SCENARIO", "simple"),
CustomTable: getEnv("CUSTOM_TABLE", ""),
}
}

Expand Down
16 changes: 16 additions & 0 deletions db/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,19 @@ func (cm *ConnectionManager) Ping(ctx context.Context) error {
defer conn.Close(ctx)
return conn.Ping(ctx)
}

// GetDatabaseSize returns the current database size in bytes
func (cm *ConnectionManager) GetDatabaseSize(ctx context.Context) (int64, error) {
conn, err := pgx.Connect(ctx, cm.connString)
if err != nil {
return 0, fmt.Errorf("failed to connect: %w", err)
}
defer conn.Close(ctx)

var size int64
err = conn.QueryRow(ctx, "SELECT pg_database_size(current_database())").Scan(&size)
if err != nil {
return 0, fmt.Errorf("failed to get database size: %w", err)
}
return size, nil
}
1 change: 1 addition & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ dist-ssr
*.njsproj
*.sln
*.sw?
pnpm-lock.yaml
12 changes: 10 additions & 2 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useWebSocket } from './hooks/useWebSocket';
import { useMetricsHistory } from './hooks/useMetricsHistory';
import { getStatus, updateConfig, start, stop, reset } from './api/client';
import { getStatus, getScenarios, updateConfig, start, stop, reset } from './api/client';
import { ConnectionStatus } from './components/ConnectionStatus';
import { ControlPanel } from './components/ControlPanel';
import { StatsPanel } from './components/StatsPanel';
Expand All @@ -15,7 +15,10 @@ function App() {
read_qps: 100,
write_qps: 10,
churn_rate: 0,
scenario: 'simple',
custom_table: '',
});
const [scenarios, setScenarios] = useState([]);
const [running, setRunning] = useState(false);
const [latestMetrics, setLatestMetrics] = useState(null);

Expand All @@ -28,12 +31,16 @@ function App() {
// Memoize display data to prevent unnecessary re-computations
const displayData = useMemo(() => getDisplayData(), [getDisplayData]);

// Fetch initial status
// Fetch initial status and scenarios
useEffect(() => {
getStatus().then((status) => {
setRunning(status.running);
setConfig(status.config);
}).catch(console.error);

getScenarios().then((data) => {
setScenarios(data.scenarios || []);
}).catch(console.error);
}, []);

// Update metrics from WebSocket - use ref to track changes
Expand Down Expand Up @@ -116,6 +123,7 @@ function App() {
<div className="lg:col-span-1">
<ControlPanel
config={config}
scenarios={scenarios}
running={running}
onConfigChange={handleConfigChange}
onStart={handleStart}
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/api/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ export async function getStatus() {
return response.json();
}

export async function getScenarios() {
const response = await fetch(`${API_BASE}/scenarios`);
return response.json();
}

export async function updateConfig(config) {
const response = await fetch(`${API_BASE}/config`, {
method: 'POST',
Expand Down
54 changes: 53 additions & 1 deletion frontend/src/components/ControlPanel.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';

export function ControlPanel({ config, running, onConfigChange, onStart, onStop, onReset }) {
export function ControlPanel({ config, scenarios, running, onConfigChange, onStart, onStop, onReset }) {
const [localConfig, setLocalConfig] = useState(config);

useEffect(() => {
Expand All @@ -13,11 +13,63 @@ export function ControlPanel({ config, running, onConfigChange, onStart, onStop,
onConfigChange(newConfig);
};

const handleScenarioChange = (scenario) => {
const newConfig = { ...localConfig, scenario };
// Clear custom_table if not custom scenario
if (scenario !== 'custom') {
newConfig.custom_table = '';
}
setLocalConfig(newConfig);
onConfigChange(newConfig);
};

const handleCustomTableChange = (customTable) => {
const newConfig = { ...localConfig, custom_table: customTable };
setLocalConfig(newConfig);
// Don't auto-submit custom table - wait for blur or enter
};

const submitCustomTable = () => {
onConfigChange(localConfig);
};

return (
<div className="bg-slate-800 rounded-lg p-6 space-y-6">
<h2 className="text-lg font-semibold text-white">Control Panel</h2>

<div className="space-y-4">
{/* Scenario Selector */}
<div className="space-y-2">
<label className="block text-sm text-slate-300">Schema Scenario</label>
<select
value={localConfig.scenario || 'simple'}
onChange={(e) => handleScenarioChange(e.target.value)}
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{scenarios.map((s) => (
<option key={s.name} value={s.name}>
{s.name} - {s.description}
</option>
))}
</select>
</div>

{/* Custom Table Input */}
{localConfig.scenario === 'custom' && (
<div className="space-y-2">
<label className="block text-sm text-slate-300">Table Name</label>
<input
type="text"
value={localConfig.custom_table || ''}
onChange={(e) => handleCustomTableChange(e.target.value)}
onBlur={submitCustomTable}
onKeyDown={(e) => e.key === 'Enter' && submitCustomTable()}
placeholder="schema.table_name"
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder-slate-500"
/>
</div>
)}

<SliderControl
label="Connections"
value={localConfig.connections}
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/components/StatsPanel.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { memo } from 'react';
import { formatNumber, formatPercent } from '../utils/formatting';
import { formatNumber, formatPercent, formatBytes } from '../utils/formatting';

function StatsPanelInner({ metrics }) {
if (!metrics) {
Expand All @@ -14,7 +14,7 @@ function StatsPanelInner({ metrics }) {
return (
<div className="bg-slate-800 rounded-lg p-6">
<h2 className="text-lg font-semibold text-white mb-4">Statistics</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<StatCard
label="Total Queries"
value={formatNumber(metrics.totals.queries)}
Expand All @@ -33,6 +33,10 @@ function StatsPanelInner({ metrics }) {
label="Active Connections"
value={`${metrics.pool.active_connections}`}
/>
<StatCard
label="Database Size"
value={formatBytes(metrics.pool.database_size_bytes)}
/>
</div>
</div>
);
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/utils/formatting.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,14 @@ export function formatLatency(ms) {
export function formatPercent(rate) {
return (rate * 100).toFixed(3) + '%';
}

export function formatBytes(bytes) {
if (bytes === 0 || bytes === undefined || bytes === null) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const k = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = bytes / Math.pow(k, i);
return value.toFixed(i > 0 ? 2 : 0) + ' ' + units[i];
}
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ module supafirehose
go 1.25.1

require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.8.0
golang.org/x/time v0.14.0
)

require (
github.com/brianvoe/gofakeit/v6 v6.28.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
Expand Down
Loading