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
78 changes: 78 additions & 0 deletions analytics/app/cell-birthdays/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
'use client';

import CellAgeMap from '@/components/charts/CellAgeMap';
import { exportRowsToCsv } from '@/lib/export';
import ExportButton from '@/components/ui/ExportButton';

const sampleCells = [
{ geohash: 'u09tvq0', lat: 40.7128, lng: -74.0060, gistCount: 2841, ageDays: 3, lastActivity: '2026-07-28' },
{ geohash: 'u09tvq1', lat: 40.7130, lng: -74.0058, gistCount: 1562, ageDays: 12, lastActivity: '2026-07-20' },
{ geohash: 'u09tvq2', lat: 40.7132, lng: -74.0056, gistCount: 892, ageDays: 45, lastActivity: '2026-06-15' },
{ geohash: 'u09tvq3', lat: 40.7126, lng: -74.0062, gistCount: 3421, ageDays: 1, lastActivity: '2026-07-29' },
{ geohash: 'u09tvq4', lat: 40.7124, lng: -74.0064, gistCount: 723, ageDays: 89, lastActivity: '2026-05-01' },
{ geohash: 'u09tvq5', lat: 40.7134, lng: -74.0054, gistCount: 456, ageDays: 180, lastActivity: '2026-01-30' },
{ geohash: 'u09tvq6', lat: 40.7118, lng: -74.0070, gistCount: 98, ageDays: 365, lastActivity: '2025-07-30' },
{ geohash: 'u09tvq7', lat: 40.7140, lng: -74.0050, gistCount: 2100, ageDays: 7, lastActivity: '2026-07-22' },
{ geohash: 'u09tvq8', lat: 40.7120, lng: -74.0068, gistCount: 567, ageDays: 30, lastActivity: '2026-06-29' },
{ geohash: 'u09tvq9', lat: 40.7138, lng: -74.0052, gistCount: 1890, ageDays: 60, lastActivity: '2026-05-30' },
{ geohash: 'u09tvr0', lat: 40.7150, lng: -74.0040, gistCount: 3045, ageDays: 2, lastActivity: '2026-07-27' },
{ geohash: 'u09tvr1', lat: 40.7148, lng: -74.0042, gistCount: 1234, ageDays: 14, lastActivity: '2026-07-15' },
{ geohash: 'u09tvr2', lat: 40.7146, lng: -74.0044, gistCount: 678, ageDays: 90, lastActivity: '2026-04-30' },
{ geohash: 'u09tvr3', lat: 40.7152, lng: -74.0038, gistCount: 2345, ageDays: 5, lastActivity: '2026-07-24' },
{ geohash: 'u09tvr4', lat: 40.7144, lng: -74.0046, gistCount: 432, ageDays: 200, lastActivity: '2026-01-10' },
{ geohash: 'u09tvr5', lat: 40.7154, lng: -74.0036, gistCount: 1567, ageDays: 21, lastActivity: '2026-07-08' },
{ geohash: 'u09tvr6', lat: 40.7136, lng: -74.0058, gistCount: 890, ageDays: 150, lastActivity: '2026-03-01' },
{ geohash: 'u09tvr7', lat: 40.7142, lng: -74.0048, gistCount: 210, ageDays: 400, lastActivity: '2025-06-24' },
{ geohash: 'u09tvr8', lat: 40.7156, lng: -74.0034, gistCount: 2789, ageDays: 4, lastActivity: '2026-07-25' },
{ geohash: 'u09tvr9', lat: 40.7135, lng: -74.0055, gistCount: 3456, ageDays: 0, lastActivity: '2026-07-29' },
{ geohash: 'u09tvs0', lat: 34.0522, lng: -118.2437, gistCount: 1890, ageDays: 10, lastActivity: '2026-07-19' },
{ geohash: 'u09tvs1', lat: 34.0524, lng: -118.2435, gistCount: 2340, ageDays: 6, lastActivity: '2026-07-23' },
{ geohash: 'u09tvs2', lat: 34.0520, lng: -118.2439, gistCount: 567, ageDays: 75, lastActivity: '2026-05-15' },
{ geohash: 'u09tvs3', lat: 34.0526, lng: -118.2433, gistCount: 1234, ageDays: 20, lastActivity: '2026-07-09' },
{ geohash: 'u09tvs4', lat: 34.0518, lng: -118.2441, gistCount: 345, ageDays: 250, lastActivity: '2025-11-22' },
{ geohash: 'u09tvs5', lat: 51.5074, lng: -0.1278, gistCount: 4567, ageDays: 1, lastActivity: '2026-07-28' },
{ geohash: 'u09tvs6', lat: 51.5076, lng: -0.1276, gistCount: 2890, ageDays: 8, lastActivity: '2026-07-21' },
{ geohash: 'u09tvs7', lat: 51.5072, lng: -0.1280, gistCount: 1678, ageDays: 35, lastActivity: '2026-06-24' },
{ geohash: 'u09tvs8', lat: 51.5078, lng: -0.1274, gistCount: 901, ageDays: 120, lastActivity: '2026-03-31' },
{ geohash: 'u09tvs9', lat: 51.5070, lng: -0.1282, gistCount: 234, ageDays: 300, lastActivity: '2025-10-03' },
];

export default function CellBirthdaysPage() {
return (
<main style={{ maxWidth: 1100, margin: '0 auto', padding: '40px 24px 64px' }}>
<div style={{
background: 'linear-gradient(135deg,#ffffff 0%,#f0fdf4 100%)',
borderRadius: 24, padding: '28px 28px 24px',
boxShadow: '0 12px 40px rgba(15,23,42,0.07)', marginBottom: 28,
}}>
<div style={{
display: 'inline-flex', alignItems: 'center', borderRadius: 999,
padding: '5px 12px', background: '#22c55e', color: '#fff',
fontSize: 11, fontWeight: 700, letterSpacing: '0.08em',
textTransform: 'uppercase', marginBottom: 10,
}}>Cell Birthdays</div>
<div className="flex items-start justify-between gap-4">
<div>
<h1 style={{ margin: '0 0 6px', fontSize: 30, fontWeight: 800 }}>Geohash Cell Birthday Tracker</h1>
<p style={{ margin: 0, color: '#475569', fontSize: 15 }}>
Track the age and activity of geohash cells across the GistPin platform. Identify aging cells with declining activity.
</p>
</div>
<ExportButton
onExport={(onProgress) =>
exportRowsToCsv({
filenamePrefix: 'cell-birthdays',
rows: sampleCells.map((c) => ({ geohash: c.geohash, lat: c.lat, lng: c.lng, age_days: c.ageDays, gist_count: c.gistCount, last_activity: c.lastActivity })),
onProgress,
})
}
/>
</div>
</div>

<div style={{ background: '#fff', borderRadius: 22, padding: 24, border: '1px solid rgba(148,163,184,0.16)' }}>
<CellAgeMap cells={sampleCells} />
</div>
</main>
);
}
215 changes: 215 additions & 0 deletions analytics/components/charts/CellAgeMap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
'use client';

import { useMemo, useState } from 'react';

interface GeohashCell {
geohash: string;
lat: number;
lng: number;
gistCount: number;
ageDays: number;
lastActivity: string;
}

interface CellAgeMapProps {
cells: GeohashCell[];
}

function geohashToBounds(geohash: string): { minLat: number; maxLat: number; minLng: number; maxLng: number } {
const BASE32 = '0123456789bcdefghjkmnpqrstuvwxyz';
let minLat = -90; let maxLat = 90;
let minLng = -180; let maxLng = 180;
let isEven = true;

for (const char of geohash) {
const idx = BASE32.indexOf(char);
for (let i = 4; i >= 0; i--) {
const bit = (idx >> i) & 1;
if (isEven) {
const mid = (minLng + maxLng) / 2;
if (bit === 1) minLng = mid;
else maxLng = mid;
} else {
const mid = (minLat + maxLat) / 2;
if (bit === 1) minLat = mid;
else maxLat = mid;
}
isEven = !isEven;
}
}
return { minLat, maxLat, minLng, maxLng };
}

function ageColor(ageDays: number): string {
if (ageDays <= 7) return '#22c55e';
if (ageDays <= 30) return '#84cc16';
if (ageDays <= 90) return '#eab308';
if (ageDays <= 180) return '#f97316';
if (ageDays <= 365) return '#ef4444';
return '#7c3aed';
}

function ageLabel(ageDays: number): string {
if (ageDays <= 7) return '< 1 week';
if (ageDays <= 30) return '< 1 month';
if (ageDays <= 90) return '< 3 months';
if (ageDays <= 180) return '< 6 months';
if (ageDays <= 365) return '< 1 year';
return '> 1 year';
}

export default function CellAgeMap({ cells }: CellAgeMapProps) {
const [minAge, setMinAge] = useState(0);
const [maxAge, setMaxAge] = useState(Infinity);
const [sortBy, setSortBy] = useState<'age' | 'count'>('age');

const filteredCells = useMemo(
() => cells
.filter((c) => c.ageDays >= minAge && c.ageDays <= maxAge)
.sort((a, b) => sortBy === 'age' ? b.ageDays - a.ageDays : b.gistCount - a.gistCount),
[cells, minAge, maxAge, sortBy]
);

const stats = useMemo(() => {
if (cells.length === 0) return null;
const ages = cells.map((c) => c.ageDays);
return {
totalCells: cells.length,
avgAge: Math.round(ages.reduce((s, v) => s + v, 0) / ages.length),
maxAge: Math.max(...ages),
minAge: Math.min(...ages),
totalGists: cells.reduce((s, c) => s + c.gistCount, 0),
};
}, [cells]);

return (
<div style={{ display: 'grid', gap: 20 }}>
{stats && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12 }}>
{[
{ label: 'Active Cells', value: stats.totalCells },
{ label: 'Avg Age', value: `${stats.avgAge} days` },
{ label: 'Oldest Cell', value: `${stats.maxAge} days` },
{ label: 'Total Gists', value: stats.totalGists.toLocaleString() },
].map((s) => (
<div key={s.label} style={{ background: '#f8fafc', borderRadius: 12, padding: '12px 16px', textAlign: 'center' }}>
<div style={{ fontSize: 11, color: '#64748b', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em' }}>{s.label}</div>
<div style={{ fontSize: 22, fontWeight: 700, marginTop: 4 }}>{s.value}</div>
</div>
))}
</div>
)}

<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<label style={{ fontSize: 13, color: '#475569' }}>Min Age (days):</label>
<input type="number" value={minAge} min={0} onChange={(e) => setMinAge(Number(e.target.value))} style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid #e2e8f0', width: 70, fontSize: 13 }} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<label style={{ fontSize: 13, color: '#475569' }}>Max Age (days):</label>
<input type="number" value={maxAge === Infinity ? '' : maxAge} min={0} onChange={(e) => setMaxAge(e.target.value ? Number(e.target.value) : Infinity)} style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid #e2e8f0', width: 70, fontSize: 13 }} />
</div>
<div style={{ display: 'flex', gap: 4 }}>
{(['age', 'count'] as const).map((s) => (
<button
key={s}
onClick={() => setSortBy(s)}
style={{
border: '1px solid #e2e8f0',
borderRadius: 6,
padding: '4px 10px',
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
background: sortBy === s ? '#1e293b' : '#fff',
color: sortBy === s ? '#fff' : '#475569',
}}
>
Sort by {s === 'age' ? 'Age' : 'Gist Count'}
</button>
))}
</div>
</div>

<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{[
{ label: '< 1 week', color: '#22c55e', min: 0, max: 7 },
{ label: '< 1 month', color: '#84cc16', min: 8, max: 30 },
{ label: '< 3 months', color: '#eab308', min: 31, max: 90 },
{ label: '< 6 months', color: '#f97316', min: 91, max: 180 },
{ label: '< 1 year', color: '#ef4444', min: 181, max: 365 },
{ label: '> 1 year', color: '#7c3aed', min: 366, max: Infinity },
].map((bucket) => {
const count = filteredCells.filter((c) => c.ageDays >= bucket.min && c.ageDays <= bucket.max).length;
return (
<div
key={bucket.label}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 10px',
borderRadius: 8,
background: bucket.color + '15',
fontSize: 12,
}}
>
<span style={{ width: 10, height: 10, borderRadius: 3, background: bucket.color }} />
<span style={{ color: '#475569' }}>{bucket.label}</span>
<span style={{ fontWeight: 700 }}>{count}</span>
</div>
);
})}
</div>

<div style={{ display: 'grid', gap: 8 }}>
{filteredCells.slice(0, 100).map((cell) => {
const bounds = geohashToBounds(cell.geohash);
const centerLat = ((bounds.minLat + bounds.maxLat) / 2).toFixed(4);
const centerLng = ((bounds.minLng + bounds.maxLng) / 2).toFixed(4);
return (
<div
key={cell.geohash}
style={{
display: 'grid',
gridTemplateColumns: '1fr auto auto auto',
gap: 12,
alignItems: 'center',
padding: '8px 14px',
borderRadius: 10,
background: '#fff',
border: '1px solid #f1f5f9',
fontSize: 13,
}}
>
<div>
<span style={{ fontFamily: 'monospace', fontWeight: 600 }}>{cell.geohash}</span>
<span style={{ color: '#94a3b8', marginLeft: 8 }}>
({centerLat}, {centerLng})
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: ageColor(cell.ageDays) }} />
<span>{cell.ageDays}d — {ageLabel(cell.ageDays)}</span>
</div>
<div style={{ color: '#64748b' }}>{cell.gistCount.toLocaleString()} gists</div>
<div style={{ fontSize: 11, color: '#94a3b8' }}>{cell.lastActivity}</div>
</div>
);
})}
</div>

{filteredCells.length > 100 && (
<div style={{ textAlign: 'center', fontSize: 13, color: '#94a3b8', padding: 12 }}>
Showing 100 of {filteredCells.length} cells. Refine filters to narrow results.
</div>
)}

{filteredCells.length === 0 && (
<div style={{ textAlign: 'center', padding: 40, color: '#94a3b8', fontSize: 14 }}>
No geohash cells match the current filters.
</div>
)}
</div>
);
}
70 changes: 70 additions & 0 deletions infrastructure/docs/garbage-collection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Kubernetes Garbage Collection Tuning

Kubelet garbage collection configuration for GistPin Kubernetes clusters.

## Overview

Proper garbage collection tuning prevents node pressure, evictions, and performance degradation. The configuration in `infrastructure/k8s/kubelet-gc-config.yaml` defines thresholds for image GC, container log rotation, and eviction policies.

## Image Garbage Collection

Images are garbage collected when disk usage exceeds the high threshold:

| Parameter | Value | Description |
|-----------|-------|-------------|
| `imageGCHighThresholdPercent` | 85% | Trigger image GC above this usage |
| `imageGCLowThresholdPercent` | 80% | Stop image GC at this usage |
| `imageMinimumGCAge` | 2m | Minimum age before image removal |

## Container Log Rotation

| Parameter | Value | Description |
|-----------|-------|-------------|
| `containerLogMaxSize` | 50Mi | Max log file size before rotation |
| `containerLogMaxFiles` | 5 | Max log files to retain per container |

## Eviction Thresholds

### Hard Eviction (immediate pod termination)

| Signal | Threshold |
|--------|-----------|
| `memory.available` | < 500Mi |
| `nodefs.available` | < 10% |
| `nodefs.inodesFree` | < 5% |
| `imagefs.available` | < 15% |

### Soft Eviction (graceful termination)

| Signal | Threshold | Grace Period |
|--------|-----------|--------------|
| `memory.available` | < 800Mi | 1m30s |
| `nodefs.available` | < 15% | 2m |
| `nodefs.inodesFree` | < 10% | 2m |
| `imagefs.available` | < 20% | 2m |

## Reserved Resources

Resources reserved for system and kubelet daemons:

| Cgroup | CPU | Memory | Ephemeral Storage |
|--------|-----|--------|-------------------|
| kubelet | 200m | 512Mi | 2Gi |
| system | 100m | 256Mi | 1Gi |

## Alerting

Alerting rules in `infrastructure/monitoring/gc-alerts.yml`:

| Alert | Severity | Description |
|-------|----------|-------------|
| GCHighImageUsage | warning | Image FS > 85% |
| GCEvictionThreshold | critical | Eviction signal breached |
| GCMemoryPressure | warning | Memory pressure |
| GCDiskPressure | warning | Disk pressure |

## Related Resources

- [Kubelet GC Config](../k8s/kubelet-gc-config.yaml)
- [GC Alerting Rules](../monitoring/gc-alerts.yml)
- [Node Alerts](../monitoring/node-alerts.yml)
Loading