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:
@@ -168,3 +168,26 @@ tools.carloselugo.com {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ services:
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
- caddy_logs:/var/log/caddy
|
||||
- /opt/void/frontend:/srv/void:ro
|
||||
networks:
|
||||
- web-net
|
||||
depends_on:
|
||||
@@ -208,6 +209,16 @@ services:
|
||||
# sysctls:
|
||||
# - 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:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
@@ -218,6 +229,7 @@ volumes:
|
||||
portainer_data:
|
||||
prometheus_data:
|
||||
grafana_data:
|
||||
hit_counter_data:
|
||||
# umami_db_data:
|
||||
|
||||
networks:
|
||||
|
||||
+3
-3
@@ -3,12 +3,12 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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" />
|
||||
<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.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>
|
||||
<body>
|
||||
<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>
|
||||
@@ -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 |
@@ -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>
|
||||
`
|
||||
}
|
||||
@@ -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 33–40%',
|
||||
'AIOps adoption reached 94% of enterprises in 2025, up 12 points year over year, with the market itself projected at $15–19B in 2026 and $37–42B by 2030–2031',
|
||||
'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> </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>
|
||||
`
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
}
|
||||
@@ -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>
|
||||
`
|
||||
}
|
||||
@@ -3,22 +3,20 @@ export function renderFooter() {
|
||||
return `
|
||||
<footer class="footer">
|
||||
<div class="container footer-inner">
|
||||
<span class="mono footer-copy">© ${year} Carlos Lugo</span>
|
||||
<span class="mono footer-copy" style="color:var(--muted)">carloselugo.com</span>
|
||||
<span class="footer-copy">© ${year} Carlos Lugo</span>
|
||||
<span class="footer-copy" style="color:var(--muted)">Puerto Rico</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.footer {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 24px 0;
|
||||
}
|
||||
.footer { padding: 32px 0; }
|
||||
.footer-inner {
|
||||
display: flex;
|
||||
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>
|
||||
`
|
||||
}
|
||||
@@ -2,18 +2,14 @@ export function renderHeader() {
|
||||
return `
|
||||
<header class="site-header">
|
||||
<div class="container header-inner">
|
||||
<a href="/" class="logo">
|
||||
<span class="logo-bracket mono">[</span>
|
||||
<span class="logo-name">clugo</span>
|
||||
<span class="logo-bracket mono">]</span>
|
||||
</a>
|
||||
<a href="/" class="logo">Carlos Lugo</a>
|
||||
<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="#blog" class="nav-link">blog</a>
|
||||
<a href="#about" class="nav-link">about</a>
|
||||
<a href="https://github.com" target="_blank" rel="noopener" class="nav-link nav-link--ext">
|
||||
github ↗
|
||||
</a>
|
||||
<a href="#tools" class="nav-link">tools</a>
|
||||
<a href="#articles" class="nav-link">articles</a>
|
||||
<a href="#contact" class="nav-link nav-link--cta">get in touch</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
@@ -24,14 +20,16 @@ export function renderHeader() {
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(10, 10, 10, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 2px solid var(--border-strong);
|
||||
background: var(--bg);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.header-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.logo {
|
||||
text-decoration: none;
|
||||
@@ -40,21 +38,26 @@ export function renderHeader() {
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text);
|
||||
transition: color 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.logo:hover .logo-name { color: var(--accent); }
|
||||
.logo-bracket { color: var(--accent); }
|
||||
.logo-name { color: var(--text); }
|
||||
.nav { display: flex; gap: 28px; }
|
||||
.nav { display: flex; align-items: center; gap: 24px; margin-left: auto; }
|
||||
.nav-link {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.03em;
|
||||
letter-spacing: 0.01em;
|
||||
white-space: nowrap;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.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>
|
||||
`
|
||||
}
|
||||
@@ -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() {
|
||||
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 `
|
||||
<section class="hero" id="about">
|
||||
<div class="container hero-inner">
|
||||
<div class="hero-label mono">// hello world</div>
|
||||
<h1 class="hero-title">
|
||||
Carlos<br/>
|
||||
<span class="hero-title--accent">Lugo.</span>
|
||||
</h1>
|
||||
<p class="hero-sub">
|
||||
Homelab tinkerer. Network nerd.<br/>
|
||||
Building things that run 24/7 in a rack at home.
|
||||
<section class="hero">
|
||||
<div class="container">
|
||||
<span class="hero-kicker reveal">Network Infrastructure & AIOps Consulting</span>
|
||||
<h1 class="hero-title reveal">No job is impossible.<br/>You just have to start.</h1>
|
||||
<p class="hero-sub reveal">
|
||||
I've built my career in tight-budget, high-expectation environments — the kind of terrain that
|
||||
forces you to be agile, pragmatic, and all-in when it counts. That's why when something gets
|
||||
handed to me, people already expect it to get done.
|
||||
</p>
|
||||
<div class="hero-stack">
|
||||
<span class="tag">docker</span>
|
||||
<span class="tag">linux</span>
|
||||
<span class="tag">networking</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 class="hero-cta reveal">
|
||||
<a class="btn btn-primary" href="#contact">Start a conversation</a>
|
||||
<a class="btn btn-secondary" href="https://www.linkedin.com/in/carlos-lugo-4319b932/" target="_blank" rel="noopener">LinkedIn ↗</a>
|
||||
<span class="tag tag-outline">Puerto Rico</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stats">
|
||||
<div class="container stats-grid reveal">
|
||||
${stats}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
min-height: 88vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 80px 0;
|
||||
}
|
||||
.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 {
|
||||
.hero { padding: 96px 0 56px; }
|
||||
.hero-kicker {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 24px;
|
||||
animation: fadeUp 0.8s ease both;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.hero-title {
|
||||
font-size: clamp(3.5rem, 10vw, 8rem);
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.04em;
|
||||
margin-bottom: 32px;
|
||||
animation: fadeUp 0.8s 0.1s ease both;
|
||||
font-size: clamp(2.4rem, 5vw, 4rem);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
max-width: 22ch;
|
||||
}
|
||||
.hero-title--accent { color: var(--accent); }
|
||||
.hero-sub {
|
||||
font-size: 1.1rem;
|
||||
color: var(--muted);
|
||||
max-width: 380px;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 32px;
|
||||
animation: fadeUp 0.8s 0.2s ease both;
|
||||
line-height: 1.6;
|
||||
max-width: 62ch;
|
||||
color: var(--muted-2);
|
||||
margin-top: 24px;
|
||||
}
|
||||
.hero-stack {
|
||||
.hero-cta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
animation: fadeUp 0.8s 0.3s ease both;
|
||||
align-items: center;
|
||||
margin-top: 32px;
|
||||
}
|
||||
.hero-gfx {
|
||||
position: absolute;
|
||||
right: -80px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
pointer-events: none;
|
||||
|
||||
.stats { padding: 0 0 64px; }
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 32px 24px;
|
||||
border-top: 2px solid var(--border-strong);
|
||||
padding-top: 32px;
|
||||
}
|
||||
.gfx-ring {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
@media (max-width: 720px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
.gfx-ring--1 { width: 300px; height: 300px; }
|
||||
.gfx-ring--2 { width: 480px; height: 480px; border-color: #151515; }
|
||||
.gfx-dot {
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
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;
|
||||
.stat-num {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 800;
|
||||
font-size: clamp(1.6rem, 3vw, 2.4rem);
|
||||
color: var(--accent);
|
||||
}
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { box-shadow: 0 0 20px var(--accent); }
|
||||
50% { box-shadow: 0 0 40px var(--accent), 0 0 80px var(--accent); }
|
||||
.stat-label {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-2);
|
||||
margin-top: 8px;
|
||||
max-width: 20ch;
|
||||
}
|
||||
</style>
|
||||
`
|
||||
|
||||
@@ -1,57 +1,46 @@
|
||||
const projects = [
|
||||
{
|
||||
id: '01',
|
||||
title: 'Enshrouded Server',
|
||||
desc: 'Dedicated game server running on bare metal. Docker Compose, Watchtower for auto-updates, daily backups.',
|
||||
tags: ['docker', 'ubuntu', 'game server'],
|
||||
status: 'live',
|
||||
},
|
||||
{
|
||||
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',
|
||||
title: 'Pathfinder 2.0',
|
||||
desc: 'Real-time flow analytics platform on Kafka, ClickHouse, PostgreSQL, Flask, and D3.js — giving stakeholders CGNAT and CMTS utilization visibility.',
|
||||
tags: ['Kafka', 'ClickHouse', 'D3.js'],
|
||||
status: 'in production',
|
||||
},
|
||||
{
|
||||
id: '03',
|
||||
title: 'This Site',
|
||||
desc: 'Vite + VanillaJS served behind Caddy with automatic SSL. Zero frameworks, full control.',
|
||||
tags: ['vite', 'caddy', 'docker'],
|
||||
title: 'Capacity Forecasting Models',
|
||||
desc: 'Prophet-based time-series models predicting regional capacity needs, guiding an annual core baseline budget in the tens of millions.',
|
||||
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',
|
||||
},
|
||||
]
|
||||
|
||||
const statusLabel = {
|
||||
live: { text: 'live', color: '#c8ff00' },
|
||||
wip: { text: 'wip', color: '#ff6b35' },
|
||||
}
|
||||
|
||||
export function renderProjects() {
|
||||
const cards = projects.map(p => {
|
||||
const s = statusLabel[p.status]
|
||||
return `
|
||||
<article class="card reveal">
|
||||
<div class="card-top">
|
||||
<span class="card-id mono">${p.id}</span>
|
||||
<span class="card-status mono" style="color:${s.color}">● ${s.text}</span>
|
||||
</div>
|
||||
<h3 class="card-title">${p.title}</h3>
|
||||
<p class="card-desc">${p.desc}</p>
|
||||
<div class="card-tags">
|
||||
${p.tags.map(t => `<span class="tag">${t}</span>`).join('')}
|
||||
</div>
|
||||
</article>
|
||||
`
|
||||
}).join('')
|
||||
const cards = projects.map(p => `
|
||||
<article class="card reveal">
|
||||
<div class="card-top">
|
||||
<span class="card-id mono">${p.id}</span>
|
||||
<span class="tag tag-accent">${p.status}</span>
|
||||
</div>
|
||||
<h3 class="card-title">${p.title}</h3>
|
||||
<p class="card-desc">${p.desc}</p>
|
||||
<div class="card-tags">
|
||||
${p.tags.map(t => `<span class="tag tag-outline">${t}</span>`).join('')}
|
||||
</div>
|
||||
</article>
|
||||
`).join('')
|
||||
|
||||
return `
|
||||
<section class="projects" id="projects">
|
||||
<div class="container">
|
||||
<div class="section-head reveal">
|
||||
<div class="accent-line"></div>
|
||||
<h2 class="section-title">Projects</h2>
|
||||
</div>
|
||||
<span class="section-kicker">Selected Work</span>
|
||||
<div class="card-grid">
|
||||
${cards}
|
||||
</div>
|
||||
@@ -59,50 +48,28 @@ export function renderProjects() {
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.projects { padding: 100px 0; }
|
||||
.section-head { margin-bottom: 48px; }
|
||||
.section-title {
|
||||
font-size: clamp(2rem, 5vw, 3.5rem);
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.projects { padding: 64px 0; }
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--border);
|
||||
border: 1px solid var(--border);
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
padding: 32px;
|
||||
transition: background 0.2s;
|
||||
cursor: default;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.card:hover { background: #161616; }
|
||||
.card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.card-id { font-size: 0.7rem; color: var(--muted); letter-spacing: 0.1em; }
|
||||
.card-status { font-size: 0.7rem; letter-spacing: 0.05em; }
|
||||
.card-title {
|
||||
font-size: 1.3rem;
|
||||
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; }
|
||||
.card-id { font-size: 0.7rem; color: var(--muted); letter-spacing: 0.08em; }
|
||||
.card-title { font-size: 1.1rem; }
|
||||
.card-desc { font-size: 0.85rem; color: var(--muted-2); line-height: 1.6; flex: 1; }
|
||||
.card-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: auto; }
|
||||
</style>
|
||||
`
|
||||
}
|
||||
@@ -1,103 +1,46 @@
|
||||
const tools = [
|
||||
{
|
||||
id: '01',
|
||||
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 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: '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' },
|
||||
]
|
||||
|
||||
const statusLabel = {
|
||||
live: { text: 'live', color: '#c8ff00' },
|
||||
wip: { text: 'wip', color: '#ff6b35' },
|
||||
}
|
||||
|
||||
export function renderTools() {
|
||||
const cards = tools.map(t => {
|
||||
const s = statusLabel[t.status]
|
||||
return `
|
||||
<a class="tool-card reveal" href="${t.url}" target="_blank" rel="noopener">
|
||||
<div class="card-top">
|
||||
<span class="card-id mono">${t.id}</span>
|
||||
<span class="card-status mono" style="color:${s.color}">● ${s.text}</span>
|
||||
const cards = TOOLS.map(t => `
|
||||
<a class="card tool-card reveal" href="${t.url}" target="_blank" rel="noopener">
|
||||
<div class="card-top">
|
||||
<span class="card-id mono">${t.id}</span>
|
||||
<span class="tag tag-accent">live</span>
|
||||
</div>
|
||||
<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>
|
||||
<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}</span>`).join('')}
|
||||
</div>
|
||||
<span class="tool-link mono">open ↗</span>
|
||||
</div>
|
||||
</a>
|
||||
`
|
||||
}).join('')
|
||||
<span class="tool-open">open ↗</span>
|
||||
</div>
|
||||
</a>
|
||||
`).join('')
|
||||
|
||||
return `
|
||||
<section class="tools" id="tools">
|
||||
<div class="container">
|
||||
<div class="section-head reveal">
|
||||
<div class="accent-line"></div>
|
||||
<h2 class="section-title">Tools</h2>
|
||||
<p class="section-sub">Self-hosted utilities running on the homelab.</p>
|
||||
</div>
|
||||
<div class="tool-grid">
|
||||
<span class="section-kicker">Self-Built Tools</span>
|
||||
<div class="card-grid">
|
||||
${cards}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.tools { padding: 0 0 100px; }
|
||||
.section-sub {
|
||||
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; }
|
||||
.tools { padding: 0 0 64px; }
|
||||
.tool-card { text-decoration: none; color: inherit; }
|
||||
.tool-bottom {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-top: 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
.tool-link {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
transition: color 0.2s, letter-spacing 0.2s;
|
||||
flex-shrink: 0;
|
||||
align-items: flex-end;
|
||||
margin-top: auto;
|
||||
}
|
||||
.tool-open { font-size: 0.75rem; color: var(--accent); }
|
||||
</style>
|
||||
`
|
||||
}
|
||||
+25
-9
@@ -1,10 +1,14 @@
|
||||
import './style.css'
|
||||
import { renderHeader } from './components/header.js'
|
||||
import { renderHero } from './components/hero.js'
|
||||
import { renderHeader } from './components/header.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 { renderTools } from './components/tools.js'
|
||||
import { renderBlog, initBlog } from './components/blog.js'
|
||||
import { renderFooter } from './components/footer.js'
|
||||
import { renderTools } from './components/tools.js'
|
||||
import { renderArticles, initArticles } from './components/articles.js'
|
||||
import { renderContact } from './components/contact.js'
|
||||
import { renderFooter } from './components/footer.js'
|
||||
|
||||
const app = document.getElementById('app')
|
||||
|
||||
@@ -12,14 +16,18 @@ app.innerHTML = `
|
||||
${renderHeader()}
|
||||
<main>
|
||||
${renderHero()}
|
||||
${renderExpertise()}
|
||||
${renderAbout()}
|
||||
${renderExperience()}
|
||||
${renderProjects()}
|
||||
${renderTools()}
|
||||
${renderBlog()}
|
||||
${renderArticles()}
|
||||
</main>
|
||||
${renderContact()}
|
||||
${renderFooter()}
|
||||
`
|
||||
|
||||
// Scroll reveals
|
||||
// Intersection Observer for scroll reveals
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
@@ -31,7 +39,15 @@ const observer = new IntersectionObserver(
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
)
|
||||
|
||||
document.querySelectorAll('.reveal').forEach((el) => observer.observe(el))
|
||||
|
||||
// Blog interactivity
|
||||
initBlog()
|
||||
initArticles()
|
||||
|
||||
// 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()
|
||||
}
|
||||
+51
-34
@@ -1,15 +1,20 @@
|
||||
/* ── Design tokens ── */
|
||||
:root {
|
||||
--bg: #0a0a0a;
|
||||
--bg-card: #111111;
|
||||
--border: #1e1e1e;
|
||||
--accent: #c8ff00; /* electric lime */
|
||||
--accent-2: #ff6b35; /* orange pop */
|
||||
--text: #e8e8e8;
|
||||
--muted: #555;
|
||||
--bg: #faf9f5;
|
||||
--bg-card: #eae9e9;
|
||||
--border: color-mix(in srgb, #201e1d 16%, transparent);
|
||||
--border-strong: color-mix(in srgb, #201e1d 40%, transparent);
|
||||
--accent: #ec3013;
|
||||
--accent-600:#dd2b0f;
|
||||
--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-sans: 'Syne', sans-serif;
|
||||
--radius: 4px;
|
||||
--font-sans: 'Archivo', sans-serif;
|
||||
--radius: 0px;
|
||||
--max-w: 1100px;
|
||||
}
|
||||
|
||||
@@ -28,17 +33,6 @@ body {
|
||||
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 ── */
|
||||
.container {
|
||||
max-width: var(--max-w);
|
||||
@@ -47,8 +41,9 @@ body::before {
|
||||
}
|
||||
|
||||
/* ── 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; }
|
||||
a { color: var(--accent); }
|
||||
|
||||
/* ── Scroll reveal ── */
|
||||
.reveal {
|
||||
@@ -61,23 +56,45 @@ code, .mono { font-family: var(--font-mono); font-size: 0.85em; }
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ── Accent line ── */
|
||||
.accent-line {
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 3px;
|
||||
background: var(--accent);
|
||||
margin-bottom: 16px;
|
||||
/* ── Divider ── */
|
||||
.hr { height: 2px; border: 0; background: var(--border-strong); margin: 0 auto; max-width: var(--max-w); }
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
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 {
|
||||
display: inline-block;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.05em;
|
||||
padding: 3px 10px;
|
||||
border-radius: 2px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.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); }
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -14,3 +14,8 @@ scrape_configs:
|
||||
- job_name: "cadvisor"
|
||||
static_configs:
|
||||
- targets: ["cadvisor:8080"]
|
||||
|
||||
- job_name: "hit-counter"
|
||||
scrape_interval: 5m
|
||||
static_configs:
|
||||
- targets: ["hit-counter:8000"]
|
||||
|
||||
Reference in New Issue
Block a user