infra/dashboard/index.html

294 lines
16 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Manohar's Hub</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" crossorigin="anonymous"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #root { min-height: 100vh; }
body { font-family: 'DM Sans', sans-serif; transition: background 0.3s, color 0.3s; }
@keyframes pulse {
0% { box-shadow: 0 0 0 0 var(--pulse-c); }
70% { box-shadow: 0 0 0 5px transparent; }
100% { box-shadow: 0 0 0 0 transparent; }
}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect } = React;
const SECTIONS = [
{ id: "infrastructure", label: "Infrastructure", services: [
{ id: "dokploy", name: "Dokploy", desc: "Docker orchestration", emoji: "🐋", url: "https://dokploy.manohargupta.com", status: "online", lastCheck: 2 },
{ id: "uptimekuma", name: "Uptime Kuma", desc: "Service monitoring", emoji: "📡", url: "https://status.manohargupta.com", status: "online", lastCheck: 1 },
{ id: "umami", name: "Umami", desc: "Web analytics", emoji: "📊", url: "https://analytics.manohargupta.com", status: "online", lastCheck: 3 },
{ id: "traefik", name: "Traefik", desc: "20 routers · 17 services", emoji: "⚡", url: "https://dokploy.manohargupta.com", status: "online", lastCheck: 1 },
]},
{ id: "tools", label: "Tools", services: [
{ id: "n8n", name: "n8n", desc: "Workflow automation", emoji: "🔄", url: "https://automate.manohargupta.com", status: "online", lastCheck: 5 },
{ id: "paperless", name: "Paperless", desc: "Document OCR + search", emoji: "📄", url: "https://docs.manohargupta.com", status: "online", lastCheck: 8 },
{ id: "miniflux", name: "Miniflux", desc: "RSS reader", emoji: "📰", url: "https://feeds.manohargupta.com", status: "online", lastCheck: 7, badge: "11 unread" },
{ id: "changedetect", name: "ChangeDetection",desc: "Page change monitor", emoji: "👁", url: "https://watch.manohargupta.com", status: "warning", lastCheck: 45 },
]},
{ id: "dev", label: "Dev", services: [
{ id: "forgejo", name: "Forgejo", desc: "Self-hosted Git", emoji: "🌿", url: "https://git.manohargupta.com", status: "online", lastCheck: 11 },
{ id: "codeserver", name: "Code Server", desc: "Browser IDE", emoji: "💻", url: "https://code.manohargupta.com", status: "online", lastCheck: 18 },
{ id: "apprise", name: "Apprise", desc: "Notification hub", emoji: "🔔", url: "https://notify.manohargupta.com", status: "offline", lastCheck: 30 },
]},
{ id: "finance", label: "Finance & Intel", services: [
{ id: "tiger", name: "Tiger Agent", desc: "AI orchestration", emoji: "🐯", url: "https://agent.manohargupta.com", status: "online", lastCheck: 6 },
{ id: "ladder", name: "Ladder", desc: "Paywall bypass · CORS proxy", emoji: "🪜", url: "https://ladder.manohargupta.com", status: "online", lastCheck: 9 },
]},
];
const QUICK_LINKS = [
{ label: "Namecheap DNS", emoji: "🌐", url: "https://ap.www.namecheap.com/domains/domaincontrolpanel/manohargupta.com/advancedns" },
{ label: "Hetzner Console", emoji: "🖥", url: "https://console.hetzner.cloud" },
{ label: "OpenRouter", emoji: "🤖", url: "https://openrouter.ai/activity" },
{ label: "BotFather", emoji: "🤖", url: "https://t.me/BotFather" },
{ label: "NSE India", emoji: "📈", url: "https://www.nseindia.com" },
{ label: "CERC", emoji: "⚡", url: "https://cercind.gov.in" },
{ label: "MNRE", emoji: "☀️", url: "https://mnre.gov.in" },
{ label: "IEX India", emoji: "💹", url: "https://www.iexindia.com" },
];
function useTime() {
const [now, setNow] = useState(new Date());
useEffect(() => { const t = setInterval(() => setNow(new Date()), 1000); return () => clearInterval(t); }, []);
return now;
}
function greeting(h) {
if (h < 12) return "Good morning";
if (h < 17) return "Good afternoon";
return "Good evening";
}
function fmtLastCheck(m) {
if (m < 1) return "just now";
if (m < 60) return `${m}m ago`;
return `${Math.round(m / 60)}h ago`;
}
function StatusDot({ status }) {
const map = { online: "#22c55e", warning: "#f59e0b", offline: "#ef4444" };
const c = map[status] || map.offline;
return (
<span style={{
display: "inline-block", width: 7, height: 7, borderRadius: "50%",
background: c, flexShrink: 0,
"--pulse-c": c + "55",
animation: status === "online" ? "pulse 2s ease infinite" : "none",
}} />
);
}
function ServiceModal({ svc, onClose }) {
if (!svc) return null;
return (
<div onClick={onClose} style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.35)", backdropFilter: "blur(4px)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1000 }}>
<div onClick={e => e.stopPropagation()} style={{ background: "var(--c-surface)", border: "1px solid var(--c-border)", borderRadius: 16, padding: "32px 36px", width: 340, boxShadow: "0 24px 64px rgba(0,0,0,0.2)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 16, marginBottom: 20 }}>
<span style={{ fontSize: 42 }}>{svc.emoji}</span>
<div>
<div style={{ fontWeight: 600, fontSize: 18, color: "var(--c-text)" }}>{svc.name}</div>
<div style={{ color: "var(--c-muted)", fontSize: 13, marginTop: 3 }}>{svc.desc}</div>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 24 }}>
<StatusDot status={svc.status} />
<span style={{ fontSize: 13, color: "var(--c-muted)", textTransform: "capitalize" }}>
{svc.status} · {fmtLastCheck(svc.lastCheck)}
</span>
</div>
<div style={{ display: "flex", gap: 10 }}>
<a href={svc.url} target="_blank" rel="noopener noreferrer"
style={{ flex: 1, background: "var(--c-accent)", color: "#fff", borderRadius: 8, padding: "11px 0", textAlign: "center", fontSize: 14, fontWeight: 500, textDecoration: "none", display: "block" }}>
Open Service
</a>
<button onClick={onClose}
style={{ flex: 1, background: "transparent", color: "var(--c-text)", border: "1px solid var(--c-border)", borderRadius: 8, padding: "11px 0", fontSize: 14, fontWeight: 500, cursor: "pointer", fontFamily: "inherit" }}>
Close
</button>
</div>
</div>
</div>
);
}
function StatCell({ val, lbl }) {
const [h, sH] = useState(false);
return (
<div onMouseEnter={() => sH(true)} onMouseLeave={() => sH(false)}
style={{ textAlign: "center", padding: "8px 20px", background: h ? "var(--c-accent-faint)" : "transparent", transition: "background 0.15s", cursor: "default" }}>
<div style={{ fontFamily: "'DM Mono', monospace", fontWeight: 500, fontSize: 15, color: h ? "var(--c-accent)" : "var(--c-text)", transition: "color 0.15s" }}>{val}</div>
<div style={{ fontSize: 11, color: h ? "var(--c-accent)" : "var(--c-muted)", marginTop: 2, letterSpacing: "0.06em", textTransform: "uppercase", transition: "color 0.15s" }}>{lbl}</div>
</div>
);
}
function ServiceCard({ svc, onClick }) {
const [h, sH] = useState(false);
return (
<div onClick={onClick} onMouseEnter={() => sH(true)} onMouseLeave={() => sH(false)}
style={{
background: h ? "var(--c-surface2)" : "var(--c-surface)",
border: `1px solid ${h ? "var(--c-accent)" : "var(--c-border)"}`,
borderRadius: 4, padding: "14px 16px", cursor: "pointer",
transform: h ? "translateY(-1px)" : "none",
boxShadow: h ? "0 4px 16px rgba(0,0,0,0.08)" : "none",
transition: "all 0.15s",
display: "flex", alignItems: "center", gap: 12, position: "relative",
}}>
<span style={{ fontSize: 28, flexShrink: 0, lineHeight: 1 }}>{svc.emoji}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 500, fontSize: 14, color: "var(--c-text)", display: "flex", alignItems: "center", gap: 6 }}>
{svc.name}
{svc.badge && (
<span style={{ background: "var(--c-accent)", color: "#fff", fontSize: 10, fontWeight: 600, padding: "1px 6px", borderRadius: 4 }}>
{svc.badge}
</span>
)}
</div>
<div style={{ fontSize: 12, color: "var(--c-muted)", marginTop: 2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{svc.desc}</div>
<div style={{ fontSize: 11, color: "var(--c-muted)", marginTop: 4, opacity: 0.7 }}>{fmtLastCheck(svc.lastCheck)}</div>
</div>
<StatusDot status={svc.status} />
{h && <div style={{ position: "absolute", right: 10, top: 10, fontSize: 10, fontWeight: 600, color: "var(--c-accent)", letterSpacing: "0.04em" }}>OPEN </div>}
</div>
);
}
function Section({ sec, onCardClick }) {
const [open, setOpen] = useState(true);
return (
<div style={{ marginBottom: 28 }}>
<button onClick={() => setOpen(o => !o)}
style={{ display: "flex", alignItems: "center", width: "100%", background: "none", border: "none", cursor: "pointer", padding: "0 0 10px", gap: 8, fontFamily: "inherit" }}>
<span style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--c-muted)" }}>{sec.label}</span>
<span style={{ marginLeft: "auto", fontSize: 12, color: "var(--c-muted)", display: "inline-block", transition: "transform 0.2s", transform: open ? "rotate(0deg)" : "rotate(-90deg)" }}></span>
</button>
{open && (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))", gap: 10 }}>
{sec.services.map(svc => (
<ServiceCard key={svc.id} svc={svc} onClick={() => onCardClick(svc)} />
))}
</div>
)}
</div>
);
}
function QuickLink({ lk }) {
const [h, sH] = useState(false);
return (
<a href={lk.url} target="_blank" rel="noopener noreferrer"
onMouseEnter={() => sH(true)} onMouseLeave={() => sH(false)}
style={{ display: "flex", alignItems: "center", gap: 7, padding: "7px 14px", background: "var(--c-surface)", border: `1px solid ${h ? "var(--c-accent)" : "var(--c-border)"}`, borderRadius: 999, fontSize: 13, color: "var(--c-text)", textDecoration: "none", whiteSpace: "nowrap", transition: "border-color 0.15s" }}>
<span style={{ fontSize: 14 }}>{lk.emoji}</span>
{lk.label}
</a>
);
}
function App() {
const [dark, setDark] = useState(() => window.matchMedia("(prefers-color-scheme: dark)").matches);
const [qlOpen, setQlOpen] = useState(true);
const [modal, setModal] = useState(null);
const now = useTime();
useEffect(() => {
const t = dark ? {
"--c-bg": "oklch(13% 0.012 180)",
"--c-surface": "oklch(17% 0.012 180)",
"--c-surface2": "oklch(21% 0.012 180)",
"--c-border": "oklch(25% 0.012 180)",
"--c-text": "oklch(92% 0.008 180)",
"--c-muted": "oklch(55% 0.01 180)",
"--c-accent": "oklch(55% 0.15 180)",
"--c-accent-faint": "oklch(55% 0.15 180 / 0.12)",
} : {
"--c-bg": "oklch(97% 0.01 180)",
"--c-surface": "oklch(99.5% 0.006 180)",
"--c-surface2": "oklch(95% 0.012 180)",
"--c-border": "oklch(88% 0.012 180)",
"--c-text": "oklch(18% 0.012 180)",
"--c-muted": "oklch(52% 0.01 180)",
"--c-accent": "oklch(55% 0.15 180)",
"--c-accent-faint": "oklch(55% 0.15 180 / 0.08)",
};
const r = document.documentElement;
Object.entries(t).forEach(([k, v]) => r.style.setProperty(k, v));
document.body.style.background = "var(--c-bg)";
document.body.style.color = "var(--c-text)";
}, [dark]);
return (
<>
<button onClick={() => setDark(d => !d)}
style={{ position: "fixed", top: 16, right: 16, zIndex: 200, width: 38, height: 38, borderRadius: "50%", background: "var(--c-surface)", border: "1px solid var(--c-border)", fontSize: 18, cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", boxShadow: "0 2px 8px rgba(0,0,0,0.1)" }}>
{dark ? "☀️" : "🌙"}
</button>
<div style={{ maxWidth: 960, margin: "0 auto", padding: "36px 24px 64px" }}>
{/* Header */}
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 40 }}>
<div>
<div style={{ fontSize: 14, color: "var(--c-muted)", marginBottom: 6 }}>{greeting(now.getHours())}, Manohar</div>
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 60, fontWeight: 500, letterSpacing: "-2px", lineHeight: 1, color: "var(--c-text)" }}>
{String(now.getHours()).padStart(2, "0")}:{String(now.getMinutes()).padStart(2, "0")}
<span style={{ fontSize: 35, opacity: 0.35, marginLeft: 4 }}>:{String(now.getSeconds()).padStart(2, "0")}</span>
</div>
<div style={{ marginTop: 6, display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 13, fontWeight: 500, color: "var(--c-muted)" }}>{now.toLocaleDateString("en-US", { weekday: "long" })}</span>
<span style={{ color: "var(--c-border)" }}>·</span>
<span style={{ fontSize: 13, color: "var(--c-muted)" }}>{now.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}</span>
</div>
</div>
<div style={{ display: "flex", alignItems: "stretch", background: "var(--c-surface)", border: "1px solid var(--c-border)", borderRadius: 999, overflow: "hidden", boxShadow: "0 1px 4px rgba(0,0,0,0.06)", alignSelf: "center" }}>
{[["3%","CPU"],["3.7 GB","RAM free"],["35 GB","Disk free"],["12d","Uptime"]].map(([v, l], i, arr) => (
<React.Fragment key={l}>
<StatCell val={v} lbl={l} />
{i < arr.length - 1 && <div style={{ width: 1, background: "var(--c-border)", margin: "8px 0" }} />}
</React.Fragment>
))}
</div>
</div>
{/* Sections */}
{SECTIONS.map(sec => <Section key={sec.id} sec={sec} onCardClick={setModal} />)}
{/* Quick Links */}
<div style={{ marginTop: 8 }}>
<button onClick={() => setQlOpen(o => !o)}
style={{ display: "flex", alignItems: "center", width: "100%", background: "none", border: "none", cursor: "pointer", padding: "0 0 12px", gap: 8, fontFamily: "inherit" }}>
<span style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--c-muted)" }}>Quick Links</span>
<span style={{ marginLeft: "auto", fontSize: 12, color: "var(--c-muted)", display: "inline-block", transition: "transform 0.2s", transform: qlOpen ? "rotate(0deg)" : "rotate(-90deg)" }}></span>
</button>
{qlOpen && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{QUICK_LINKS.map(lk => <QuickLink key={lk.label} lk={lk} />)}
</div>
)}
</div>
</div>
<ServiceModal svc={modal} onClose={() => setModal(null)} />
</>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
</script>
</body>
</html>