Add hit counter service and expand frontend content sections

- New hit-counter FastAPI service (Prometheus metrics, SQLite storage), wired into Caddy (hits.carloselugo.com) and docker-compose
- Scrape hit-counter in Prometheus every 5m
- Add articles, about, contact, experience, and expertise sections to the frontend, with per-article hit tracking

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 20:31:52 +00:00
co-authored by Claude Sonnet 5
parent fd961a0668
commit 44d6c1e5a9
21 changed files with 959 additions and 320 deletions
+23
View File
@@ -168,3 +168,26 @@ tools.carloselugo.com {
format json format json
} }
} }
# ── Void — void.carloselugo.com ───────────────────────────────────────────────
void.carloselugo.com {
import security_headers
root * /srv/void
file_server
encode gzip
log {
output file /var/log/caddy/void.log
format json
}
}
# ── Hit Counter — hits.carloselugo.com ────────────────────────────────────────
hits.carloselugo.com {
import security_headers
reverse_proxy hit-counter:8000
encode gzip
log {
output file /var/log/caddy/hit-counter.log
format json
}
}
+12
View File
@@ -13,6 +13,7 @@ services:
- caddy_data:/data - caddy_data:/data
- caddy_config:/config - caddy_config:/config
- caddy_logs:/var/log/caddy - caddy_logs:/var/log/caddy
- /opt/void/frontend:/srv/void:ro
networks: networks:
- web-net - web-net
depends_on: depends_on:
@@ -208,6 +209,16 @@ services:
# sysctls: # sysctls:
# - net.ipv6.conf.all.disable_ipv6=1 # - net.ipv6.conf.all.disable_ipv6=1
# ─── Hit Counter ───────────────────────────────────────────────────────────
hit-counter:
build: ./hit-counter
container_name: hit-counter
restart: unless-stopped
volumes:
- hit_counter_data:/data
networks:
- web-net
volumes: volumes:
caddy_data: caddy_data:
caddy_config: caddy_config:
@@ -218,6 +229,7 @@ volumes:
portainer_data: portainer_data:
prometheus_data: prometheus_data:
grafana_data: grafana_data:
hit_counter_data:
# umami_db_data: # umami_db_data:
networks: networks:
+3 -3
View File
@@ -3,12 +3,12 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Carlos Lugo — homelab, games, and tech." /> <meta name="description" content="Carlos Lugo — Network Infrastructure & AIOps Consulting." />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Carlos Lugo</title> <title>Carlos Lugo — Network Infrastructure & AIOps Consulting</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Syne:wght@400;600;800&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Archivo:ital,wght@0,400;0,600;0,800;1,400&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>From SNMP Polling to Kafka Streaming and AI: The Evolution of Network Monitoring — Carlos Lugo</title>
<meta name="description" content="Why polling every five minutes leaves a network blind most of the time — and the streaming architecture the whole industry is moving toward." />
<!-- Open Graph (LinkedIn, Facebook) -->
<meta property="og:type" content="article" />
<meta property="og:title" content="From SNMP Polling to Kafka Streaming and AI: The Evolution of Network Monitoring" />
<meta property="og:description" content="Why polling every five minutes leaves a network blind most of the time — and the streaming architecture the whole industry is moving toward." />
<meta property="og:url" content="https://carloselugo.com/articles/snmp-to-kafka-aiops.html" />
<meta property="og:image" content="https://carloselugo.com/images/profile_rev2.png" />
<meta property="og:site_name" content="Carlos Lugo" />
<!-- Twitter/X card (harmless to include, some tools reuse it) -->
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="From SNMP Polling to Kafka Streaming and AI: The Evolution of Network Monitoring" />
<meta name="twitter:description" content="Why polling every five minutes leaves a network blind most of the time — and the streaming architecture the whole industry is moving toward." />
<meta name="twitter:image" content="https://carloselugo.com/images/profile_rev2.png" />
<link rel="canonical" href="https://carloselugo.com/#article-snmp-to-kafka-aiops" />
<!-- Human visitors get redirected straight into the real article on the SPA. -->
<meta http-equiv="refresh" content="0; url=/#article-snmp-to-kafka-aiops" />
<script>window.location.replace('/#article-snmp-to-kafka-aiops');</script>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background:#faf9f5; color:#201e1d;
display:flex; align-items:center; justify-content:center; min-height:100vh; margin:0; }
a { color:#ec3013; }
</style>
</head>
<body>
<noscript>
<p>Redirecting… If nothing happens, <a href="/#article-snmp-to-kafka-aiops">read the article here</a>.</p>
</noscript>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" fill="#201e1d"/>
<text x="50" y="66" font-family="Arial, sans-serif" font-size="44" font-weight="800" fill="#ec3013" text-anchor="middle">CL</text>
</svg>

After

Width:  |  Height:  |  Size: 252 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 MiB

+77
View File
@@ -0,0 +1,77 @@
export function renderAbout() {
return `
<hr class="hr" />
<section class="about" id="about">
<div class="container about-inner">
<div class="about-photo reveal" id="about-photo">
<img src="/images/profile_rev2.png" alt="Carlos Lugo" onerror="this.parentElement.classList.add('about-photo--empty'); this.remove();" />
</div>
<div class="about-copy reveal">
<span class="section-kicker">About</span>
<h2 class="about-title">From the field to the dashboard.</h2>
<p class="about-p">
I'm Carlos Lugo, based in Puerto Rico. My career started operating ISP, MAN, and MPLS networks
on the ground, and over two decades it's grown into leading capacity planning, technology
architecture, and now network intelligence for one of the region's largest operators.
</p>
<p class="about-p">
Today I build the analytics platforms and AI-driven models that guide infrastructure decisions
at scale — and I take on select consulting engagements for teams who need that same translation
from telemetry to strategy.
</p>
<div class="about-tags">
<span class="tag tag-neutral">Cisco IOS-XR</span>
<span class="tag tag-neutral">Juniper JunOS</span>
<span class="tag tag-neutral">Kafka / ClickHouse</span>
<span class="tag tag-neutral">Python / Flask</span>
<span class="tag tag-neutral">Fortigate / Palo Alto</span>
</div>
<p class="about-meta">
A.S. Instrumentation Technology, UPR Bayamón · Former Cisco CCNP · Spanish (native), English (professional)
</p>
</div>
</div>
</section>
<hr class="hr" />
<style>
.about { padding: 64px 0; }
.about-inner {
display: grid;
grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
gap: 48px;
align-items: center;
}
.about-photo {
width: 100%;
max-width: 340px;
aspect-ratio: 1 / 1;
border-radius: 50%;
overflow: hidden;
background: var(--bg-card);
}
.about-photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
.about-photo--empty {
display: flex;
align-items: center;
justify-content: center;
border: 2px dashed var(--border-strong);
}
.about-photo--empty::after {
content: 'CL';
font-family: var(--font-sans);
font-weight: 800;
font-size: 3rem;
color: var(--accent);
}
.about-title { font-size: 2rem; margin-bottom: 20px; }
.about-p { font-size: 1rem; line-height: 1.7; color: var(--muted-2); max-width: 56ch; margin-bottom: 16px; }
.about-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 24px; }
.about-meta { font-size: 0.8rem; color: var(--muted); margin-top: 24px; }
@media (max-width: 780px) {
.about-inner { grid-template-columns: 1fr; }
.about-photo { max-width: 220px; margin: 0 auto; }
}
</style>
`
}
+258
View File
@@ -0,0 +1,258 @@
function withArrows(steps) {
return steps.map((s, i) => ({ ...s, hasNext: i < steps.length - 1 }))
}
const ARTICLES = [
{
id: 'snmp-to-kafka-aiops',
visible: true,
category: 'AIOps',
title: 'From SNMP Polling to Kafka Streaming and AI: The Evolution of Network Monitoring',
excerpt: 'Why polling every five minutes leaves a network blind most of the time — and the streaming architecture the whole industry is moving toward.',
blocks: [
{ p: 'For three decades, SNMP polling has been the default way to watch a network. But polling a router every five minutes means everything that happens between polls — micro-bursts, self-healing BGP flaps, control-plane overload from answering thousands of OIDs — is invisible.' },
{ p: 'The cost of operating this way is no longer just technical, it is financial: network downtime costs organizations an average of $5,600 per minute, with large enterprises losing over $300,000 per hour. That figure alone justifies rethinking the monitoring model.' },
{ h4: 'The market is already voting with its budget' },
{ ul: [
'The global network telemetry market is projected to grow from roughly $0.97B in 2025 to $4.33B by 2030, a CAGR near 3340%',
'AIOps adoption reached 94% of enterprises in 2025, up 12 points year over year, with the market itself projected at $1519B in 2026 and $3742B by 20302031',
'Anomaly-detection models based on statistical baselines are cutting Mean Time to Resolution by up to 60%',
'A mid-size enterprise already generates roughly 1.5 terabytes of telemetry a day — well past what manual analysis can cover',
]},
{ p: 'Model-Driven Telemetry over gRPC/gNMI is the natural next step, streaming metrics sub-second instead of waiting on a poll cycle. But the classic starter stack — a router dial-out straight into Telegraf and InfluxDB — breaks down once it moves from a lab into production with dozens of nodes.' },
{ h4: 'Where the classic stack breaks' },
{ ul: [
'A single collector becomes a monolith and a single point of failure',
'No buffer means a slow database write drops or backs up incoming metrics',
'Feeding both a database and an ML model means the collector has to duplicate every stream by hand',
]},
{ h4: 'The fix: decouple collection, transport, and consumption' },
{ diagram: withArrows([
{ tag: '01', label: 'Routers / Switches', edgeLabel: 'gNMI dial-out' },
{ tag: '02', label: 'Distributed Collectors', edgeLabel: 'produce events' },
{ tag: '03', label: 'Kafka Event Bus', edgeLabel: 'fan-out' },
{ tag: '04', label: 'ClickHouse / TimescaleDB', edgeLabel: '' },
]), caption: 'Streaming telemetry pipeline — collection, transport, and consumption stay independent' },
{ p: 'Kafka is the piece that makes the rest possible. It absorbs traffic spikes without dropping events, and it lets a time-series database and a real-time ML model both read the same stream independently — write once, consume everywhere.' },
{ h4: 'What runs on the stream' },
{ ul: [
'ClickHouse or TimescaleDB for high-ingest analytical storage and Grafana dashboards',
'An AIOps engine consuming Kafka directly for anomaly detection and event correlation',
'Dynamic anomaly detection that replaces static thresholds with pattern-aware baselines',
]},
{ h4: 'Where the market goes next' },
{ ul: [
'Standards like IEEE 802.1Qcw are pushing multi-vendor telemetry interoperability, lowering the cost of switching providers',
'OT environments (Modbus/DNP3) and low-memory edge devices remain slower to migrate',
'Cloud-native players like Datadog and Dynatrace are pressuring legacy vendors with unified dashboards and usage-based pricing',
'Generative-AI workload observability (token consumption, model latency) is emerging as its own telemetry category — the same streaming-plus-AI pattern extending past the network into the applications running on it',
]},
{ p: 'The mindset shift matters more than any single tool: a router stops being a device you query and becomes a source of events. Kafka is the bridge that lets AI models consume those events in real time instead of polling a database every second. With 94% of enterprises already running some form of AIOps, staying on pure polling is no longer a conservative choice — it is a measurable disadvantage in MTTR and downtime cost.' },
],
},
{
id: 'tls-proxy-legacy-rescue',
visible: false,
category: 'Network Security',
title: 'How a Small TLS Proxy Saved a Mobile Security Launch From a One-Year Delay',
excerpt: "A legacy provisioning system could not speak modern TLS to a new security API. A lightweight proxy closed the gap in days, not a year — right as the industry's TLS legacy deadline closed in.",
blocks: [
{ p: "A new mobile security service — phishing and malware filtering through a dedicated APN — was ready to launch. Then integration testing hit a wall: the legacy provisioning system could not complete a TLS handshake with the security platform's API." },
{ h4: 'The root cause' },
{ ul: [
'The legacy provisioning system ran an old Java stack, capped at outdated TLS 1.0/1.1 cipher suites',
'The security API required modern TLS 1.2/1.3 ciphers, no exceptions',
]},
{ p: 'This was not a vendor being difficult: TLS 1.0/1.1 have been formally deprecated by the IETF (RFC 8996) since 2021, blocked by every major browser since 2020, and banned under PCI DSS, NIST SP 800-52 Rev. 2, and HIPAA. In 2026 the question in the industry is no longer whether to retire these protocols but how fast — Microsoft alone is running final TLS-legacy shutdowns across Meraki, Azure/Office, and Exchange Online this same year.' },
{ p: "The official fix was a full upgrade of the provisioning platform's core — 12 to 18 months of regression testing across dozens of dependent services, with no budget allocated for the year. Taking that path meant freezing the project indefinitely. This is not a rare dilemma: accumulated technical debt grew by an estimated $6 trillion globally between 2012 and 2023 (Oliver Wyman), and the average enterprise loses over $370M a year from its inability to modernize legacy systems efficiently — about $134M of that tied directly to slow, traditional transformation processes." },
{ h4: 'The alternative: a reverse proxy in the middle' },
{ diagram: withArrows([
{ tag: '01', label: 'Legacy Provisioner', edgeLabel: 'TLS 1.0/1.1' },
{ tag: '02', label: 'Reverse Proxy', edgeLabel: 'TLS 1.2/1.3' },
{ tag: '03', label: 'Security API', edgeLabel: '' },
]), caption: 'TLS termination and re-encryption isolated in a single lightweight container' },
{ p: 'The provisioner talked to the proxy using the only cipher suites it knew. The proxy terminated that legacy session, then opened a fresh, modern handshake to the security API and relayed the response back. No change touched the provisioning platform itself.' },
{ h4: 'What it delivered' },
{ ul: [
'The service launched on schedule with zero delay to the commercial roadmap',
'The infrastructure team got over a year to plan the real upgrade without launch pressure',
'The provisioning platform stayed untouched — zero regression risk introduced',
]},
{ h4: 'Why this pattern became standard in 2026' },
{ ul: [
'The TLS-legacy grace period is closing across the board — Meraki, Azure Storage, Office 365, and Exchange Online are all running their own TLS 1.0/1.1 shutdowns through 2026',
"Technical debt is now a balance-sheet problem, not just a systems one: McKinsey puts it at roughly 40% of large enterprises' IT balance sheets",
'The sidecar/proxy pattern — terminating TLS in NGINX, HAProxy, or Envoy — is now the industry-standard way to absorb exactly this kind of compatibility gap without touching critical systems',
]},
{ p: 'Technical debt always collects eventually — CAST Software put the global backlog at 61 billion person-days of remediation work in 2025 — but a well-isolated proxy bought the time to pay it down on our terms instead of the market\'s. It is also why terminating TLS at a dedicated proxy or sidecar became a standing pattern in every containerized deployment since.' },
],
},
]
function renderBlock(block) {
if (block.p) return `<p class="article-p">${block.p}</p>`
if (block.h4) return `<h4 class="article-h4">${block.h4}</h4>`
if (block.ul) {
return `<ul class="article-ul">${block.ul.map(li => `<li>${li}</li>`).join('')}</ul>`
}
if (block.diagram) {
const steps = block.diagram.map(step => `
<div class="diagram-step">
<div class="diagram-box">
<p class="diagram-tag">${step.tag}</p>
<p class="diagram-label">${step.label}</p>
</div>
${step.hasNext ? `
<div class="diagram-arrow">
<span class="diagram-arrow-glyph">↓</span>
<span class="diagram-edge-label">${step.edgeLabel}</span>
</div>
` : ''}
</div>
`).join('')
return `
<figure class="article-figure">
${block.caption ? `<figcaption class="article-caption">${block.caption}</figcaption>` : ''}
<div class="diagram">${steps}</div>
</figure>
`
}
return ''
}
function renderCard(a) {
return `
<article class="card article-card reveal">
<span class="tag tag-neutral">${a.category}</span>
<h3 class="card-title">${a.title}</h3>
<p class="card-desc">${a.excerpt}</p>
<a class="btn btn-ghost" style="align-self:flex-start;margin-top:auto;padding-left:0" href="#article-${a.id}">Read →</a>
</article>
`
}
function renderDetail(a) {
return `
<div id="article-${a.id}" class="article-detail" data-slug="${a.id}">
<div class="article-detail-inner">
<a class="btn btn-secondary" href="#articles">← Back to articles</a>
<span class="tag tag-neutral" style="margin-top:32px">${a.category}</span>
<h2 class="article-title">${a.title}</h2>
<p class="article-hits" data-hits>&nbsp;</p>
<div class="article-body">
${a.blocks.map(renderBlock).join('')}
</div>
</div>
</div>
`
}
const HITS_ENDPOINT = 'https://hits.carloselugo.com'
async function trackHit(slug) {
const el = document.querySelector(`#article-${slug} [data-hits]`)
if (!el) return
try {
const res = await fetch(`${HITS_ENDPOINT}/hit/${slug}`, { method: 'POST' })
const data = await res.json()
el.textContent = `${data.count.toLocaleString()} views`
} catch {
el.textContent = '' // fail silently, never block the article from rendering
}
}
function checkHashForArticle() {
const match = location.hash.match(/^#article-(.+)$/)
if (match) trackHit(match[1])
}
/** Call this once after inserting renderArticles() into the DOM. */
export function initArticles() {
checkHashForArticle()
window.addEventListener('hashchange', checkHashForArticle)
}
export function renderArticles() {
const visible = ARTICLES.filter(a => a.visible)
if (visible.length === 0) return ''
const cards = visible.map(renderCard).join('')
const details = visible.map(renderDetail).join('')
return `
<hr class="hr" />
<section class="articles" id="articles">
<div class="container">
<span class="section-kicker">Articles</span>
<div class="card-grid articles-list">
${cards}
</div>
</div>
</section>
${details}
<style>
.articles { padding: 64px 0; }
.article-card { text-decoration: none; }
/* Detail panels — hidden until targeted via #article-<id> anchor */
.article-detail {
display: none;
padding: 64px 0;
border-top: 2px solid var(--border-strong);
}
.article-detail:target { display: block; }
/* Hide the list section while a detail panel is open */
.articles:has(~ .article-detail:target) { display: none; }
.article-detail-inner { max-width: 720px; margin: 0 auto; padding: 0 24px; }
.article-title { font-size: clamp(1.75rem, 4vw, 2.5rem); margin: 16px 0 8px; }
.article-hits { font-size: 0.75rem; color: var(--muted); margin: 0 0 24px; font-family: var(--font-mono); }
.article-body { font-size: 1rem; line-height: 1.8; color: var(--text); }
.article-p { margin: 0 0 20px; }
.article-h4 {
font-size: 0.85rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--accent-700);
margin: 28px 0 10px;
}
.article-ul { padding-left: 20px; margin: 0 0 20px; color: var(--muted-2); }
.article-ul li { margin-bottom: 6px; }
.article-figure { margin: 8px 0 32px; border: 2px solid var(--border-strong); padding: 24px; }
.article-caption {
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent-700);
margin: 0 0 20px;
text-align: center;
}
.diagram {
display: flex;
flex-direction: column;
align-items: stretch;
max-width: 420px;
margin: 0 auto;
}
.diagram-box { border: 2px solid var(--text); padding: 14px 18px; }
.diagram-tag {
font-size: 0.6rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
margin: 0 0 6px;
}
.diagram-label { font-family: var(--font-sans); font-weight: 800; font-size: 0.85rem; }
.diagram-arrow {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0 8px 18px;
}
.diagram-arrow-glyph { font-size: 1.2rem; color: var(--accent); line-height: 1; }
.diagram-edge-label { font-size: 0.7rem; color: var(--muted); }
</style>
`
}
+26
View File
@@ -0,0 +1,26 @@
export function renderContact() {
return `
<section class="contact" id="contact">
<div class="container">
<h2 class="contact-title">Let's build something reliable.</h2>
<p class="contact-sub">
I take on select network intelligence, AIOps, and infrastructure consulting engagements —
reach out if that's what you need.
</p>
<div class="contact-cta">
<a class="btn btn-ghost" href="mailto:lugo.carlos@outlook.com">lugo.carlos@outlook.com</a>
<a class="btn btn-ghost" href="https://www.linkedin.com/in/carlos-lugo-4319b932/" target="_blank" rel="noopener">LinkedIn ↗</a>
</div>
</div>
</section>
<style>
.contact { background: var(--accent); color: var(--bg); padding: 80px 0; }
.contact-title { font-size: clamp(1.9rem, 4.2vw, 3.2rem); color: var(--bg); margin-bottom: 16px; }
.contact-sub { font-size: 1rem; max-width: 52ch; color: var(--bg); opacity: 0.9; margin-bottom: 32px; }
.contact-cta { display: flex; gap: 12px; flex-wrap: wrap; }
.contact .btn-ghost { color: var(--bg); border: 1px solid var(--bg); padding: 10px 18px; }
.contact .btn-ghost:hover { background: rgba(255,255,255,0.12); }
</style>
`
}
+72
View File
@@ -0,0 +1,72 @@
const EXPERIENCE = [
{ title: 'Senior Manager, Network Intelligence', dates: 'Jun 2025 Present', company: 'Liberty Latin America — LCPR, San Juan, PR', bullets: [
'Defining and executing the Network Intelligence & AIOps roadmap, bridging Network Operations, IT, and Customer Experience.',
'Architected Pathfinder 2.0, a real-time flow analytics platform on Kafka, ClickHouse, PostgreSQL, Flask, and D3.js, for CGNAT and CMTS utilization insight.',
'Built the AI strategy roadmap behind two production use cases: Truck Roll Reduction and Proactive Fault Detection / MTTR Reduction.',
'Leading a monitoring platform upgrade across a multi-initiative portfolio spanning monitoring, analytics, and DevOps.',
]},
{ title: 'Technology Architect Sr. Manager', dates: 'May 2023 Jun 2025', company: 'Liberty Latin America, San Juan, PR', bullets: [
'Led end-to-end network architecture design for new product initiatives across access, core, and service layers.',
'Partnered with product teams to tailor designs and maximize capital investment return.',
]},
{ title: 'Capacity Planning Sr. Manager', dates: 'Oct 2018 May 2023', company: 'Liberty Latin America, San Juan, PR', bullets: [
'Developed Prophet-based time-series forecasting models to predict regional capacity and prioritize CapEx.',
'Reports and scenario models guided a core baseline budget in the tens of millions annually.',
'Monitored performance trends to flag congestion risk ahead of service impact.',
]},
{ title: 'Core and Access Network Manager', dates: 'Feb 2010 Oct 2018', company: 'Liberty Puerto Rico, San Juan, PR', bullets: [
'Managed a multimillion-dollar annual capital budget from vendor procurement through project delivery.',
'Operated multi-technology infrastructure: CMTS, OLT, B2B MPLS, DWDM, Video Multicast, WiFi, and IT.',
]},
{ title: 'Network Operations Specialist II', dates: 'Jul 2002 Feb 2010', company: 'Centennial of Puerto Rico, Guaynabo, PR', bullets: [
'Operated ISP, MAN, B2B, MPLS, Frame Relay, and ATM network topologies.',
]},
]
export function renderExperience() {
const rows = EXPERIENCE.map(job => `
<div class="job reveal">
<div class="job-head">
<h3 class="job-title">${job.title}</h3>
<span class="job-dates mono">${job.dates}</span>
</div>
<p class="job-company">${job.company}</p>
<ul class="job-bullets">
${job.bullets.map(b => `<li>${b}</li>`).join('')}
</ul>
</div>
`).join('')
return `
<section class="experience" id="experience">
<div class="container">
<span class="section-kicker">Experience</span>
${rows}
</div>
</section>
<style>
.experience { padding: 64px 0; }
.job { padding: 28px 0; border-top: 2px solid var(--border-strong); }
.job-head {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
align-items: baseline;
}
.job-title { font-size: 1.25rem; }
.job-dates { font-size: 0.8rem; color: var(--muted); }
.job-company { font-size: 0.9rem; color: var(--accent-700); margin: 6px 0 14px; }
.job-bullets {
margin: 0;
padding-left: 20px;
color: var(--muted-2);
font-size: 0.95rem;
line-height: 1.7;
max-width: 74ch;
}
.job-bullets li { margin-bottom: 6px; }
</style>
`
}
+66
View File
@@ -0,0 +1,66 @@
const EXPERTISE = [
{ id: '01', title: 'Network & Infrastructure', desc: 'Cisco IOS-XR and Juniper JunOS at scale — BGP, MPLS, CMTS, OLT, DWDM, IPv6, CGNAT, and BNG across access, core, and service layers.' },
{ id: '02', title: 'AIOps & Analytics', desc: 'Real-time flow analytics on Kafka, ClickHouse, and PostgreSQL; IPFIX/NetFlow and SNMP pipelines; Prophet-based forecasting for capacity and demand.' },
{ id: '03', title: 'Development', desc: 'Python and Flask services, REST APIs, and D3.js dashboards that turn telemetry into something a VP can act on in one meeting.' },
{ id: '04', title: 'Security', desc: 'Building working knowledge of firewall architecture and policy (Fortigate, Palo Alto, SonicWall) to design network access with security as a first-class constraint, not an afterthought.' },
]
export function renderExpertise() {
const rows = EXPERTISE.map(item => `
<div class="expertise-row reveal">
<p class="expertise-id"><span class="expertise-dot"></span>${item.id}</p>
<h3 class="expertise-title">${item.title}</h3>
<p class="expertise-desc">${item.desc}</p>
</div>
`).join('')
return `
<section class="expertise" id="expertise">
<div class="container">
<span class="section-kicker">Areas of Expertise</span>
${rows}
</div>
</section>
<style>
.expertise { padding: 64px 0; }
.section-kicker {
display: block;
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 24px;
}
.expertise-row {
display: grid;
grid-template-columns: minmax(48px, 120px) minmax(0, 300px) minmax(0, 1fr);
gap: 24px;
align-items: baseline;
padding: 28px 0;
border-top: 2px solid var(--border-strong);
}
.expertise-id {
font-family: var(--font-sans);
font-weight: 800;
font-size: 0.9rem;
position: relative;
padding-left: 20px;
}
.expertise-dot {
position: absolute;
left: 0;
top: 6px;
width: 10px;
height: 10px;
background: var(--accent);
}
.expertise-title { font-size: 1.35rem; }
.expertise-desc { font-size: 0.95rem; line-height: 1.6; color: var(--muted-2); max-width: 52ch; }
@media (max-width: 720px) {
.expertise-row { grid-template-columns: 1fr; gap: 8px; }
}
</style>
`
}
+6 -8
View File
@@ -3,22 +3,20 @@ export function renderFooter() {
return ` return `
<footer class="footer"> <footer class="footer">
<div class="container footer-inner"> <div class="container footer-inner">
<span class="mono footer-copy">© ${year} Carlos Lugo</span> <span class="footer-copy">© ${year} Carlos Lugo</span>
<span class="mono footer-copy" style="color:var(--muted)">carloselugo.com</span> <span class="footer-copy" style="color:var(--muted)">Puerto Rico</span>
</div> </div>
</footer> </footer>
<style> <style>
.footer { .footer { padding: 32px 0; }
border-top: 1px solid var(--border);
padding: 24px 0;
}
.footer-inner { .footer-inner {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; flex-wrap: wrap;
gap: 8px;
} }
.footer-copy { font-size: 0.75rem; color: var(--muted); letter-spacing: 0.04em; } .footer-copy { font-size: 0.8rem; color: var(--muted-2); }
</style> </style>
` `
} }
+25 -22
View File
@@ -2,18 +2,14 @@ export function renderHeader() {
return ` return `
<header class="site-header"> <header class="site-header">
<div class="container header-inner"> <div class="container header-inner">
<a href="/" class="logo"> <a href="/" class="logo">Carlos Lugo</a>
<span class="logo-bracket mono">[</span>
<span class="logo-name">clugo</span>
<span class="logo-bracket mono">]</span>
</a>
<nav class="nav"> <nav class="nav">
<a href="#experience" class="nav-link">experience</a>
<a href="#expertise" class="nav-link">expertise</a>
<a href="#projects" class="nav-link">projects</a> <a href="#projects" class="nav-link">projects</a>
<a href="#blog" class="nav-link">blog</a> <a href="#tools" class="nav-link">tools</a>
<a href="#about" class="nav-link">about</a> <a href="#articles" class="nav-link">articles</a>
<a href="https://github.com" target="_blank" rel="noopener" class="nav-link nav-link--ext"> <a href="#contact" class="nav-link nav-link--cta">get in touch</a>
github ↗
</a>
</nav> </nav>
</div> </div>
</header> </header>
@@ -24,14 +20,16 @@ export function renderHeader() {
top: 0; top: 0;
z-index: 100; z-index: 100;
padding: 16px 0; padding: 16px 0;
border-bottom: 1px solid var(--border); border-bottom: 2px solid var(--border-strong);
background: rgba(10, 10, 10, 0.85); background: var(--bg);
backdrop-filter: blur(12px); overflow-x: auto;
} }
.header-inner { .header-inner {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 24px;
flex-wrap: nowrap;
} }
.logo { .logo {
text-decoration: none; text-decoration: none;
@@ -40,21 +38,26 @@ export function renderHeader() {
font-size: 1.1rem; font-size: 1.1rem;
letter-spacing: -0.02em; letter-spacing: -0.02em;
color: var(--text); color: var(--text);
transition: color 0.2s; white-space: nowrap;
} }
.logo:hover .logo-name { color: var(--accent); } .nav { display: flex; align-items: center; gap: 24px; margin-left: auto; }
.logo-bracket { color: var(--accent); }
.logo-name { color: var(--text); }
.nav { display: flex; gap: 28px; }
.nav-link { .nav-link {
font-family: var(--font-mono); font-size: 0.85rem;
font-size: 0.8rem; color: var(--text);
color: var(--muted);
text-decoration: none; text-decoration: none;
letter-spacing: 0.03em; letter-spacing: 0.01em;
white-space: nowrap;
transition: color 0.2s; transition: color 0.2s;
} }
.nav-link:hover { color: var(--accent); } .nav-link:hover { color: var(--accent); }
.nav-link--cta {
font-family: var(--font-sans);
font-weight: 800;
background: var(--accent);
color: var(--bg);
padding: 8px 16px;
}
.nav-link--cta:hover { background: var(--accent-600); color: var(--bg); }
</style> </style>
` `
} }
+74 -88
View File
@@ -1,112 +1,98 @@
const STATS = [
{ num: '20+', label: 'Years in ISP network engineering' },
{ num: '8-figure', label: 'Core CapEx guided by forecasting models, annually' },
{ num: 'Multiple', label: 'Monitoring, analytics, and DevOps initiatives led in current portfolio' },
{ num: '2', label: 'AI use cases shipped to production' },
]
export function renderHero() { export function renderHero() {
const stats = STATS.map(s => `
<div class="stat">
<p class="stat-num">${s.num}</p>
<p class="stat-label">${s.label}</p>
</div>
`).join('')
return ` return `
<section class="hero" id="about"> <section class="hero">
<div class="container hero-inner"> <div class="container">
<div class="hero-label mono">// hello world</div> <span class="hero-kicker reveal">Network Infrastructure &amp; AIOps Consulting</span>
<h1 class="hero-title"> <h1 class="hero-title reveal">No job is impossible.<br/>You just have to start.</h1>
Carlos<br/> <p class="hero-sub reveal">
<span class="hero-title--accent">Lugo.</span> I've built my career in tight-budget, high-expectation environments — the kind of terrain that
</h1> forces you to be agile, pragmatic, and all-in when it counts. That's why when something gets
<p class="hero-sub"> handed to me, people already expect it to get done.
Homelab tinkerer. Network nerd.<br/>
Building things that run 24/7 in a rack at home.
</p> </p>
<div class="hero-stack"> <div class="hero-cta reveal">
<span class="tag">docker</span> <a class="btn btn-primary" href="#contact">Start a conversation</a>
<span class="tag">linux</span> <a class="btn btn-secondary" href="https://www.linkedin.com/in/carlos-lugo-4319b932/" target="_blank" rel="noopener">LinkedIn ↗</a>
<span class="tag">networking</span> <span class="tag tag-outline">Puerto Rico</span>
<span class="tag">fortigate</span>
<span class="tag">game servers</span>
</div>
<div class="hero-gfx" aria-hidden="true">
<div class="gfx-ring gfx-ring--1"></div>
<div class="gfx-ring gfx-ring--2"></div>
<div class="gfx-dot"></div>
</div> </div>
</div> </div>
</section> </section>
<section class="stats">
<div class="container stats-grid reveal">
${stats}
</div>
</section>
<style> <style>
.hero { .hero { padding: 96px 0 56px; }
min-height: 88vh; .hero-kicker {
display: flex; display: block;
align-items: center; font-family: var(--font-mono);
position: relative; font-size: 0.75rem;
overflow: hidden; letter-spacing: 0.08em;
padding: 80px 0; text-transform: uppercase;
}
.hero::after {
content: '';
position: absolute;
bottom: 0; left: 0; right: 0;
height: 1px;
background: var(--border);
}
.hero-inner { position: relative; z-index: 1; }
.hero-label {
color: var(--accent); color: var(--accent);
font-size: 0.8rem; margin-bottom: 16px;
letter-spacing: 0.1em;
margin-bottom: 24px;
animation: fadeUp 0.8s ease both;
} }
.hero-title { .hero-title {
font-size: clamp(3.5rem, 10vw, 8rem); font-size: clamp(2.4rem, 5vw, 4rem);
line-height: 0.95; line-height: 1.1;
letter-spacing: -0.04em; letter-spacing: -0.02em;
margin-bottom: 32px; max-width: 22ch;
animation: fadeUp 0.8s 0.1s ease both;
} }
.hero-title--accent { color: var(--accent); }
.hero-sub { .hero-sub {
font-size: 1.1rem; font-size: 1.1rem;
color: var(--muted); line-height: 1.6;
max-width: 380px; max-width: 62ch;
line-height: 1.7; color: var(--muted-2);
margin-bottom: 32px; margin-top: 24px;
animation: fadeUp 0.8s 0.2s ease both;
} }
.hero-stack { .hero-cta {
display: flex; display: flex;
gap: 12px;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; align-items: center;
animation: fadeUp 0.8s 0.3s ease both; margin-top: 32px;
} }
.hero-gfx {
position: absolute; .stats { padding: 0 0 64px; }
right: -80px; .stats-grid {
top: 50%; display: grid;
transform: translateY(-50%); grid-template-columns: repeat(4, minmax(0, 1fr));
width: 500px; gap: 32px 24px;
height: 500px; border-top: 2px solid var(--border-strong);
pointer-events: none; padding-top: 32px;
} }
.gfx-ring { @media (max-width: 720px) {
position: absolute; .stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
border-radius: 50%;
border: 1px solid var(--border);
top: 50%; left: 50%;
transform: translate(-50%, -50%);
} }
.gfx-ring--1 { width: 300px; height: 300px; } .stat-num {
.gfx-ring--2 { width: 480px; height: 480px; border-color: #151515; } font-family: var(--font-sans);
.gfx-dot { font-weight: 800;
position: absolute; font-size: clamp(1.6rem, 3vw, 2.4rem);
top: 50%; left: 50%; color: var(--accent);
transform: translate(-50%, -50%);
width: 8px; height: 8px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 20px var(--accent);
animation: pulse 2s ease-in-out infinite;
} }
@keyframes fadeUp { .stat-label {
from { opacity: 0; transform: translateY(20px); } font-size: 0.72rem;
to { opacity: 1; transform: none; } letter-spacing: 0.05em;
} text-transform: uppercase;
@keyframes pulse { color: var(--muted-2);
0%, 100% { box-shadow: 0 0 20px var(--accent); } margin-top: 8px;
50% { box-shadow: 0 0 40px var(--accent), 0 0 80px var(--accent); } max-width: 20ch;
} }
</style> </style>
` `
+39 -72
View File
@@ -1,57 +1,46 @@
const projects = [ const projects = [
{ {
id: '01', id: '01',
title: 'Enshrouded Server', title: 'Pathfinder 2.0',
desc: 'Dedicated game server running on bare metal. Docker Compose, Watchtower for auto-updates, daily backups.', desc: 'Real-time flow analytics platform on Kafka, ClickHouse, PostgreSQL, Flask, and D3.js — giving stakeholders CGNAT and CMTS utilization visibility.',
tags: ['docker', 'ubuntu', 'game server'], tags: ['Kafka', 'ClickHouse', 'D3.js'],
status: 'live', status: 'in production',
},
{
id: '02',
title: 'Homelab Network',
desc: 'Fortigate firewall at the edge, VLANs, static IP. Built to host services publicly with proper segmentation.',
tags: ['fortigate', 'networking', 'vlan'],
status: 'wip',
}, },
{ {
id: '03', id: '03',
title: 'This Site', title: 'Capacity Forecasting Models',
desc: 'Vite + VanillaJS served behind Caddy with automatic SSL. Zero frameworks, full control.', desc: 'Prophet-based time-series models predicting regional capacity needs, guiding an annual core baseline budget in the tens of millions.',
tags: ['vite', 'caddy', 'docker'], tags: ['Prophet', 'Forecasting'],
status: 'live',
},
{
id: '04',
title: 'Carrier-Transition Flap Detection',
desc: 'Telemetry and SNMP pipeline for delta-based alerting across thousands of interfaces on Cisco IOS-XR and Juniper JunOS.',
tags: ['SNMP', 'Telemetry'],
status: 'live', status: 'live',
}, },
] ]
const statusLabel = {
live: { text: 'live', color: '#c8ff00' },
wip: { text: 'wip', color: '#ff6b35' },
}
export function renderProjects() { export function renderProjects() {
const cards = projects.map(p => { const cards = projects.map(p => `
const s = statusLabel[p.status] <article class="card reveal">
return ` <div class="card-top">
<article class="card reveal"> <span class="card-id mono">${p.id}</span>
<div class="card-top"> <span class="tag tag-accent">${p.status}</span>
<span class="card-id mono">${p.id}</span> </div>
<span class="card-status mono" style="color:${s.color}">● ${s.text}</span> <h3 class="card-title">${p.title}</h3>
</div> <p class="card-desc">${p.desc}</p>
<h3 class="card-title">${p.title}</h3> <div class="card-tags">
<p class="card-desc">${p.desc}</p> ${p.tags.map(t => `<span class="tag tag-outline">${t}</span>`).join('')}
<div class="card-tags"> </div>
${p.tags.map(t => `<span class="tag">${t}</span>`).join('')} </article>
</div> `).join('')
</article>
`
}).join('')
return ` return `
<section class="projects" id="projects"> <section class="projects" id="projects">
<div class="container"> <div class="container">
<div class="section-head reveal"> <span class="section-kicker">Selected Work</span>
<div class="accent-line"></div>
<h2 class="section-title">Projects</h2>
</div>
<div class="card-grid"> <div class="card-grid">
${cards} ${cards}
</div> </div>
@@ -59,50 +48,28 @@ export function renderProjects() {
</section> </section>
<style> <style>
.projects { padding: 100px 0; } .projects { padding: 64px 0; }
.section-head { margin-bottom: 48px; }
.section-title {
font-size: clamp(2rem, 5vw, 3.5rem);
letter-spacing: -0.03em;
}
.card-grid { .card-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1px; gap: 16px;
background: var(--border);
border: 1px solid var(--border);
} }
.card { .card {
background: var(--bg-card); background: var(--bg-card);
padding: 32px; padding: 20px;
transition: background 0.2s; display: flex;
cursor: default; flex-direction: column;
gap: 8px;
} }
.card:hover { background: #161616; }
.card-top { .card-top {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 20px;
} }
.card-id { font-size: 0.7rem; color: var(--muted); letter-spacing: 0.1em; } .card-id { font-size: 0.7rem; color: var(--muted); letter-spacing: 0.08em; }
.card-status { font-size: 0.7rem; letter-spacing: 0.05em; } .card-title { font-size: 1.1rem; }
.card-title { .card-desc { font-size: 0.85rem; color: var(--muted-2); line-height: 1.6; flex: 1; }
font-size: 1.3rem; .card-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: auto; }
font-weight: 800;
letter-spacing: -0.02em;
margin-bottom: 12px;
}
.card-desc {
font-size: 0.9rem;
color: var(--muted);
line-height: 1.65;
margin-bottom: 20px;
}
.card-tags { display: flex; flex-wrap: wrap; gap: 6px; }
/* Staggered reveal for cards */
.card-grid .card:nth-child(2) { transition-delay: 0.1s; }
.card-grid .card:nth-child(3) { transition-delay: 0.2s; }
</style> </style>
` `
} }
+26 -83
View File
@@ -1,103 +1,46 @@
const tools = [ const TOOLS = [
{ { id: '01', title: 'MarkItDown', desc: 'Convert any document to Markdown instantly — drop in a PDF, DOCX, PPTX, XLSX, HTML, or CSV and get clean output back.', tags: ['Python', 'FastAPI'], url: 'https://md.carloselugo.com' },
id: '01', { id: '02', title: 'IT Tools', desc: 'A self-hosted collection of developer utilities — CIDR calculator, JSON formatter, Base64, JWT decoder, cron parser, and more.', tags: ['Docker', 'DevTools'], url: 'https://tools.carloselugo.com' },
title: 'MarkItDown',
desc: 'Convert any document to Markdown. Drop a PDF, DOCX, PPTX, XLSX, HTML, or CSV and get clean Markdown output instantly.',
tags: ['python', 'fastapi', 'markitdown'],
url: 'https://md.carloselugo.com',
status: 'live',
},
{
id: '02',
title: 'IT Tools',
desc: 'Collection of handy developer utilities — CIDR calculator, JSON formatter, base64, JWT decoder, cron parser, and 80+ more.',
tags: ['docker', 'devtools', 'networking'],
url: 'https://tools.carloselugo.com',
status: 'live',
},
] ]
const statusLabel = {
live: { text: 'live', color: '#c8ff00' },
wip: { text: 'wip', color: '#ff6b35' },
}
export function renderTools() { export function renderTools() {
const cards = tools.map(t => { const cards = TOOLS.map(t => `
const s = statusLabel[t.status] <a class="card tool-card reveal" href="${t.url}" target="_blank" rel="noopener">
return ` <div class="card-top">
<a class="tool-card reveal" href="${t.url}" target="_blank" rel="noopener"> <span class="card-id mono">${t.id}</span>
<div class="card-top"> <span class="tag tag-accent">live</span>
<span class="card-id mono">${t.id}</span> </div>
<span class="card-status mono" style="color:${s.color}">● ${s.text}</span> <h3 class="card-title">${t.title}</h3>
<p class="card-desc">${t.desc}</p>
<div class="tool-bottom">
<div class="card-tags">
${t.tags.map(tag => `<span class="tag tag-outline">${tag}</span>`).join('')}
</div> </div>
<h3 class="card-title">${t.title}</h3> <span class="tool-open">open ↗</span>
<p class="card-desc">${t.desc}</p> </div>
<div class="tool-bottom"> </a>
<div class="card-tags"> `).join('')
${t.tags.map(tag => `<span class="tag">${tag}</span>`).join('')}
</div>
<span class="tool-link mono">open ↗</span>
</div>
</a>
`
}).join('')
return ` return `
<section class="tools" id="tools"> <section class="tools" id="tools">
<div class="container"> <div class="container">
<div class="section-head reveal"> <span class="section-kicker">Self-Built Tools</span>
<div class="accent-line"></div> <div class="card-grid">
<h2 class="section-title">Tools</h2>
<p class="section-sub">Self-hosted utilities running on the homelab.</p>
</div>
<div class="tool-grid">
${cards} ${cards}
</div> </div>
</div> </div>
</section> </section>
<style> <style>
.tools { padding: 0 0 100px; } .tools { padding: 0 0 64px; }
.section-sub { .tool-card { text-decoration: none; color: inherit; }
font-size: 0.88rem;
color: var(--muted);
margin-top: 8px;
}
.tool-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
margin-top: 48px;
}
.tool-card {
background: var(--bg-card);
padding: 32px;
transition: background 0.2s;
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
gap: 0;
}
.tool-card:hover { background: #161616; }
.tool-card:hover .tool-link { color: var(--accent); letter-spacing: 0.12em; }
.tool-bottom { .tool-bottom {
display: flex; display: flex;
align-items: flex-end;
justify-content: space-between; justify-content: space-between;
margin-top: 20px; align-items: flex-end;
gap: 12px; margin-top: auto;
}
.tool-link {
font-size: 0.72rem;
color: var(--muted);
white-space: nowrap;
transition: color 0.2s, letter-spacing 0.2s;
flex-shrink: 0;
} }
.tool-open { font-size: 0.75rem; color: var(--accent); }
</style> </style>
` `
} }
+25 -9
View File
@@ -1,10 +1,14 @@
import './style.css' import './style.css'
import { renderHeader } from './components/header.js' import { renderHeader } from './components/header.js'
import { renderHero } from './components/hero.js' import { renderHero } from './components/hero.js'
import { renderExpertise } from './components/expertise.js'
import { renderAbout } from './components/about.js'
import { renderExperience } from './components/experience.js'
import { renderProjects } from './components/projects.js' import { renderProjects } from './components/projects.js'
import { renderTools } from './components/tools.js' import { renderTools } from './components/tools.js'
import { renderBlog, initBlog } from './components/blog.js' import { renderArticles, initArticles } from './components/articles.js'
import { renderFooter } from './components/footer.js' import { renderContact } from './components/contact.js'
import { renderFooter } from './components/footer.js'
const app = document.getElementById('app') const app = document.getElementById('app')
@@ -12,14 +16,18 @@ app.innerHTML = `
${renderHeader()} ${renderHeader()}
<main> <main>
${renderHero()} ${renderHero()}
${renderExpertise()}
${renderAbout()}
${renderExperience()}
${renderProjects()} ${renderProjects()}
${renderTools()} ${renderTools()}
${renderBlog()} ${renderArticles()}
</main> </main>
${renderContact()}
${renderFooter()} ${renderFooter()}
` `
// Scroll reveals // Intersection Observer for scroll reveals
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
entries.forEach((entry) => { entries.forEach((entry) => {
@@ -31,7 +39,15 @@ const observer = new IntersectionObserver(
}, },
{ threshold: 0.1 } { threshold: 0.1 }
) )
document.querySelectorAll('.reveal').forEach((el) => observer.observe(el)) document.querySelectorAll('.reveal').forEach((el) => observer.observe(el))
// Blog interactivity initArticles()
initBlog()
// If the URL already points to an anchor (e.g. an article deep link),
// jump to it now — the browser's native auto-scroll only works for
// elements present at initial page load, and ours are inserted after.
if (location.hash) {
const target = document.querySelector(location.hash)
if (target) target.scrollIntoView()
}
+52 -35
View File
@@ -1,15 +1,20 @@
/* ── Design tokens ── */ /* ── Design tokens ── */
:root { :root {
--bg: #0a0a0a; --bg: #faf9f5;
--bg-card: #111111; --bg-card: #eae9e9;
--border: #1e1e1e; --border: color-mix(in srgb, #201e1d 16%, transparent);
--accent: #c8ff00; /* electric lime */ --border-strong: color-mix(in srgb, #201e1d 40%, transparent);
--accent-2: #ff6b35; /* orange pop */ --accent: #ec3013;
--text: #e8e8e8; --accent-600:#dd2b0f;
--muted: #555; --accent-700:#ae1800;
--accent-100:#fff2ef;
--accent-800:#7c1405;
--text: #201e1d;
--muted: color-mix(in srgb, #201e1d 60%, transparent);
--muted-2: color-mix(in srgb, #201e1d 78%, transparent);
--font-mono: 'Space Mono', monospace; --font-mono: 'Space Mono', monospace;
--font-sans: 'Syne', sans-serif; --font-sans: 'Archivo', sans-serif;
--radius: 4px; --radius: 0px;
--max-w: 1100px; --max-w: 1100px;
} }
@@ -28,17 +33,6 @@ body {
overflow-x: hidden; overflow-x: hidden;
} }
/* ── Noise overlay ── */
body::before {
content: '';
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 9999;
opacity: 0.4;
}
/* ── Layout ── */ /* ── Layout ── */
.container { .container {
max-width: var(--max-w); max-width: var(--max-w);
@@ -47,8 +41,9 @@ body::before {
} }
/* ── Typography ── */ /* ── Typography ── */
h1, h2, h3 { font-family: var(--font-sans); font-weight: 800; line-height: 1.1; } h1, h2, h3, h4 { font-family: var(--font-sans); font-weight: 800; line-height: 1.1; letter-spacing: -0.015em; }
code, .mono { font-family: var(--font-mono); font-size: 0.85em; } code, .mono { font-family: var(--font-mono); font-size: 0.85em; }
a { color: var(--accent); }
/* ── Scroll reveal ── */ /* ── Scroll reveal ── */
.reveal { .reveal {
@@ -61,23 +56,45 @@ code, .mono { font-family: var(--font-mono); font-size: 0.85em; }
transform: none; transform: none;
} }
/* ── Accent line ── */ /* ── Divider ── */
.accent-line { .hr { height: 2px; border: 0; background: var(--border-strong); margin: 0 auto; max-width: var(--max-w); }
display: inline-block;
width: 40px; /* ── Buttons ── */
height: 3px; .btn {
background: var(--accent); display: inline-flex;
margin-bottom: 16px; align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
text-decoration: none;
font-family: var(--font-sans);
font-weight: 800;
font-size: 0.9rem;
line-height: 1.2;
color: var(--text);
background: transparent;
border: 1px solid transparent;
padding: 10px 18px;
border-radius: var(--radius);
white-space: nowrap;
transition: background 0.2s, color 0.2s, border-color 0.2s;
} }
.btn-primary { background: var(--accent); color: var(--bg); }
.btn-primary:hover { background: var(--accent-600); }
.btn-secondary { border-color: var(--border-strong); }
.btn-secondary:hover { background: color-mix(in srgb, var(--text) 7%, transparent); }
.btn-ghost { color: var(--accent); padding-inline: 4px; }
/* ── Tag pill ── */ /* ── Tag pill ── */
.tag { .tag {
display: inline-block; display: inline-flex;
align-items: center;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.7rem; font-size: 0.7rem;
padding: 2px 8px; padding: 3px 10px;
border: 1px solid var(--border); border-radius: 2px;
border-radius: var(--radius); letter-spacing: 0.03em;
color: var(--muted); }
letter-spacing: 0.05em; .tag-neutral { background: var(--bg-card); color: var(--muted-2); }
} .tag-accent { background: var(--accent-100); color: var(--accent-800); }
.tag-outline { border: 1px solid var(--accent); color: var(--accent); }
+6
View File
@@ -0,0 +1,6 @@
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir fastapi uvicorn[standard] prometheus_client
COPY main.py .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+120
View File
@@ -0,0 +1,120 @@
"""
hit-counter — minimal per-slug visit counter, with basic abuse protection
and a Prometheus /metrics endpoint for Grafana.
Throttling (in-memory, fine at this scale):
1. Per (ip, slug) cooldown — same visitor re-hitting the same article
within COOLDOWN_SECONDS doesn't increment the count again.
2. Per-ip global rate limit — caps total requests/minute from one IP.
State resets on container restart — acceptable for this use case.
"""
import time
from collections import defaultdict
from pathlib import Path
import sqlite3
from fastapi import FastAPI, Request, HTTPException, Response
from fastapi.middleware.cors import CORSMiddleware
from prometheus_client import Gauge, generate_latest, CONTENT_TYPE_LATEST
DB_PATH = Path("/data/hits.db")
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
COOLDOWN_SECONDS = 30 * 60 # one counted hit per (ip, slug) per 30 min
RATE_LIMIT_WINDOW = 60 # seconds
RATE_LIMIT_MAX_REQUESTS = 20 # requests per ip per window, across all endpoints
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["https://carloselugo.com"],
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
# (ip, slug) -> last counted timestamp
_last_hit: dict[tuple[str, str], float] = {}
# ip -> list of recent request timestamps
_request_log: dict[str, list[float]] = defaultdict(list)
# Prometheus gauge, one series per article slug.
# Gauge (not Counter) because we set it directly from the SQLite total
# on every /metrics scrape — Prometheus itself builds the time series
# from repeated scrapes, so we don't need to track history ourselves.
article_hits_gauge = Gauge("article_hits_total", "Total counted views per article", ["slug"])
def get_client_ip(request: Request) -> str:
# Caddy sets X-Forwarded-For when reverse-proxying.
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def enforce_rate_limit(ip: str):
now = time.time()
log = _request_log[ip]
while log and now - log[0] > RATE_LIMIT_WINDOW:
log.pop(0)
if len(log) >= RATE_LIMIT_MAX_REQUESTS:
raise HTTPException(status_code=429, detail="Too many requests")
log.append(now)
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.execute(
"CREATE TABLE IF NOT EXISTS hits (slug TEXT PRIMARY KEY, count INTEGER NOT NULL DEFAULT 0)"
)
return conn
@app.post("/hit/{slug}")
def register_hit(slug: str, request: Request):
ip = get_client_ip(request)
enforce_rate_limit(ip)
now = time.time()
key = (ip, slug)
should_count = now - _last_hit.get(key, 0) > COOLDOWN_SECONDS
conn = get_db()
if should_count:
conn.execute(
"INSERT INTO hits (slug, count) VALUES (?, 1) "
"ON CONFLICT(slug) DO UPDATE SET count = count + 1",
(slug,),
)
conn.commit()
_last_hit[key] = now
total = conn.execute("SELECT count FROM hits WHERE slug = ?", (slug,)).fetchone()
conn.close()
count = total[0] if total else 0
article_hits_gauge.labels(slug=slug).set(count)
return {"slug": slug, "count": count, "counted": should_count}
@app.get("/hits/{slug}")
def get_hits(slug: str, request: Request):
enforce_rate_limit(get_client_ip(request))
conn = get_db()
row = conn.execute("SELECT count FROM hits WHERE slug = ?", (slug,)).fetchone()
conn.close()
return {"slug": slug, "count": row[0] if row else 0}
@app.get("/metrics")
def metrics():
# Refresh every gauge from the source of truth (SQLite) before
# Prometheus scrapes, so /metrics is correct even right after a restart.
conn = get_db()
rows = conn.execute("SELECT slug, count FROM hits").fetchall()
conn.close()
for slug, count in rows:
article_hits_gauge.labels(slug=slug).set(count)
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
+5
View File
@@ -14,3 +14,8 @@ scrape_configs:
- job_name: "cadvisor" - job_name: "cadvisor"
static_configs: static_configs:
- targets: ["cadvisor:8080"] - targets: ["cadvisor:8080"]
- job_name: "hit-counter"
scrape_interval: 5m
static_configs:
- targets: ["hit-counter:8000"]