docs: refresh homepage with icons, comparison, and closing CTA (#4877)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Amir Raminfar
2026-07-29 10:25:43 -07:00
committed by GitHub
parent ae1753bcee
commit 48f04a0a7d
10 changed files with 304 additions and 30 deletions
+32 -7
View File
@@ -1,6 +1,26 @@
declare const data: { stars: number; pulls: number }; declare const data: {
stars: number;
pulls: number;
contributors: number;
release: { version: string; date: string } | null;
};
export { data }; export { data };
// The contributors endpoint has no total count, so ask for one per page and read
// the last page number out of the Link header.
async function fetchContributorCount() {
const res = await fetch("https://api.github.com/repos/amir20/dozzle/contributors?per_page=1&anon=false");
const match = res.headers.get("link")?.match(/[?&]page=(\d+)>; rel="last"/);
return match ? parseInt(match[1], 10) : 0;
}
async function fetchLatestRelease() {
const res = await fetch("https://api.github.com/repos/amir20/dozzle/releases/latest");
const json = await res.json();
if (!json.tag_name) return null;
return { version: json.tag_name, date: json.published_at };
}
export default { export default {
async load() { async load() {
const urls = [ const urls = [
@@ -8,13 +28,18 @@ export default {
"https://hub.docker.com/v2/namespaces/amir20/repositories/dozzle", "https://hub.docker.com/v2/namespaces/amir20/repositories/dozzle",
]; ];
const responses = await Promise.all(urls.map((url) => fetch(url).then((res) => res.json()))); const [repo, hub, contributors, release] = await Promise.all([
...urls.map((url) => fetch(url).then((res) => res.json())),
// Never fail the build over a rate-limited or flaky API call.
fetchContributorCount().catch(() => 0),
fetchLatestRelease().catch(() => null),
]);
const data = { return {
stars: responses[0].stargazers_count, stars: repo.stargazers_count,
pulls: responses[1].pull_count, pulls: hub.pull_count,
contributors,
release,
}; };
return data;
}, },
}; };
@@ -0,0 +1,77 @@
<script lang="ts" setup>
import { withBase } from "vitepress";
import InstallCommand from "./InstallCommand.vue";
</script>
<template>
<section class="mx-auto mt-20 max-w-[1152px] px-6">
<div
class="flex flex-col items-center rounded-2xl border border-solid border-(--vp-c-divider) bg-(--vp-c-bg-alt) px-6 py-16"
>
<h2 class="m-0! text-center text-2xl font-semibold text-(--vp-c-text-1) md:text-3xl">
Start watching your containers
</h2>
<p class="mt-3! mb-0! max-w-xl text-center text-(--vp-c-text-2)">
One command, no agent to install, no account to create.
</p>
<div class="mt-8 w-full">
<InstallCommand :hint="false" dense />
</div>
<div class="mt-10 flex flex-wrap items-center justify-center gap-3">
<a class="cta-button cta-brand" :href="withBase('/guide/getting-started')">Get Started</a>
<a class="cta-button cta-alt" href="https://github.com/amir20/dozzle">
<Icon icon="simple-icons:github" class="size-4" />
View on GitHub
</a>
</div>
<p class="mt-8! mb-0! text-sm text-(--vp-c-text-2)">MIT licensed and free forever.</p>
</div>
</section>
</template>
<style scoped>
/* Mirror VitePress's own button tokens so these match the hero buttons in both themes. */
.cta-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.5rem;
border: 1px solid transparent;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 600;
line-height: 1.5;
text-decoration: none !important;
transition:
background-color 0.25s,
border-color 0.25s,
color 0.25s;
}
.cta-brand {
border-color: var(--vp-button-brand-border);
background-color: var(--vp-button-brand-bg);
color: var(--vp-button-brand-text);
}
.cta-brand:hover {
border-color: var(--vp-button-brand-hover-border);
background-color: var(--vp-button-brand-hover-bg);
color: var(--vp-button-brand-hover-text);
}
.cta-alt {
border-color: var(--vp-button-alt-border);
background-color: var(--vp-button-alt-bg);
color: var(--vp-button-alt-text);
}
.cta-alt:hover {
border-color: var(--vp-button-alt-hover-border);
background-color: var(--vp-button-alt-hover-bg);
color: var(--vp-button-alt-hover-text);
}
</style>
@@ -0,0 +1,42 @@
<script lang="ts" setup>
import { computed } from "vue";
import { data } from "../../activity.data";
const cues = [
{ icon: "mdi:scale-balance", label: "MIT licensed" },
{ icon: "mdi:package-variant-closed", label: "Single binary, no database" },
{ icon: "mdi:server-security", label: "Self-hosted, your logs stay on your network" },
];
const released = computed(() => {
if (!data.release) return null;
const days = Math.floor((Date.now() - new Date(data.release.date).getTime()) / 86_400_000);
if (days <= 0) return "released today";
if (days === 1) return "released yesterday";
if (days < 30) return `released ${days} days ago`;
const months = Math.round(days / 30);
return `released ${months} month${months === 1 ? "" : "s"} ago`;
});
</script>
<template>
<div class="mt-8 flex flex-col gap-2 text-sm text-(--vp-c-text-2)">
<ul class="m-0! flex list-none flex-wrap gap-x-5 gap-y-2 p-0!">
<li v-for="cue in cues" :key="cue.label" class="flex items-center gap-1.5">
<Icon :icon="cue.icon" class="size-4 opacity-70" />
{{ cue.label }}
</li>
</ul>
<a
v-if="data.release"
class="flex w-fit items-center gap-1.5 text-(--vp-c-text-3) no-underline! transition-colors hover:text-(--vp-c-brand)"
:href="`https://github.com/amir20/dozzle/releases/tag/${data.release.version}`"
>
<span class="relative flex size-2">
<span class="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-60"></span>
<span class="relative inline-flex size-2 rounded-full bg-emerald-500"></span>
</span>
{{ data.release.version }} {{ released }}
</a>
</div>
</template>
@@ -2,6 +2,11 @@
import { useClipboard } from "@vueuse/core"; import { useClipboard } from "@vueuse/core";
import { withBase } from "vitepress"; import { withBase } from "vitepress";
const { hint = true, dense = false } = defineProps<{
hint?: boolean;
dense?: boolean;
}>();
const command = const command =
"docker run -d -v /var/run/docker.sock:/var/run/docker.sock -v dozzle_data:/data -p 8080:8080 amir20/dozzle:latest"; "docker run -d -v /var/run/docker.sock:/var/run/docker.sock -v dozzle_data:/data -p 8080:8080 amir20/dozzle:latest";
@@ -9,7 +14,7 @@ const { copy, copied } = useClipboard({ source: command, copiedDuring: 2000 });
</script> </script>
<template> <template>
<div class="mx-auto mb-14 flex max-w-[1152px] flex-col items-center gap-3 px-6"> <div class="mx-auto flex max-w-[1152px] flex-col items-center gap-3 px-6" :class="dense ? 'mb-0' : 'mb-14'">
<div <div
class="flex w-full max-w-4xl items-start gap-3 rounded-lg border border-solid border-(--vp-c-divider) bg-(--vp-c-bg-alt) py-3 pr-3 pl-4" class="flex w-full max-w-4xl items-start gap-3 rounded-lg border border-solid border-(--vp-c-divider) bg-(--vp-c-bg-alt) py-3 pr-3 pl-4"
> >
@@ -27,7 +32,7 @@ const { copy, copied } = useClipboard({ source: command, copiedDuring: 2000 });
{{ copied ? "Copied" : "Copy" }} {{ copied ? "Copied" : "Copy" }}
</button> </button>
</div> </div>
<p class="m-0! text-sm text-(--vp-c-text-2)"> <p v-if="hint" class="m-0! text-sm text-(--vp-c-text-2)">
Then open <span class="font-mono">http://localhost:8080</span>. Prefer Compose, Swarm, or Kubernetes? See the Then open <span class="font-mono">http://localhost:8080</span>. Prefer Compose, Swarm, or Kubernetes? See the
<a :href="withBase('/guide/getting-started')">install guide</a>. <a :href="withBase('/guide/getting-started')">install guide</a>.
</p> </p>
+38 -13
View File
@@ -1,24 +1,49 @@
<template> <template>
<div class="stats stats-horizontal mt-10"> <div class="mt-10 flex flex-wrap gap-x-14 gap-y-8">
<a class="stat w-40 border-0! no-underline!" href="https://github.com/amir20/dozzle/stargazers"> <a
<div class="stat-title text-(--vp-c-text-1)">Github Stars</div> v-for="stat in stats"
<div class="stat-value"> :key="stat.label"
<Counter :start="0" :end="data.stars" :duration="1000" :formatter="compact" /> class="flex flex-col no-underline! transition-colors hover:text-(--vp-c-brand)"
</div> :href="stat.href"
</a> >
<span class="flex items-center gap-2 text-sm text-(--vp-c-text-2)">
<a class="stat border-0! no-underline!" href="https://hub.docker.com/r/amir20/dozzle"> <Icon :icon="stat.icon" class="size-4 opacity-70" />
<div class="stat-title text-(--vp-c-text-1)">Docker Pulls</div> {{ stat.label }}
<div class="stat-value"> </span>
<Counter :start="0" :end="data.pulls" :duration="1000" :formatter="compact" /> <span class="mt-2.5 text-4xl leading-none font-semibold tracking-tight text-(--vp-c-text-1) tabular-nums">
</div> <Counter :start="0" :end="stat.value" :duration="1000" :formatter="compact" />
</span>
</a> </a>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from "vue";
import { data } from "../../activity.data"; import { data } from "../../activity.data";
import Counter from "./Counter.vue"; import Counter from "./Counter.vue";
const stats = computed(() =>
[
{
icon: "simple-icons:github",
label: "Github Stars",
value: data.stars,
href: "https://github.com/amir20/dozzle/stargazers",
},
{
icon: "simple-icons:docker",
label: "Docker Pulls",
value: data.pulls,
href: "https://hub.docker.com/r/amir20/dozzle",
},
{
icon: "mdi:account-group-outline",
label: "Contributors",
value: data.contributors,
href: "https://github.com/amir20/dozzle/graphs/contributors",
},
].filter((stat) => stat.value),
);
const compact = (value: number) => { const compact = (value: number) => {
if (value >= 1e9) return (value / 1e9).toFixed(1) + "B"; if (value >= 1e9) return (value / 1e9).toFixed(1) + "B";
if (value >= 1e6) return Math.round(value / 1e6) + "M"; if (value >= 1e6) return Math.round(value / 1e6) + "M";
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
<script lang="ts" setup>
// Real quotes only. Drop in verbatim text with a link back to the source
// (GitHub issue, HN thread, r/selfhosted post) so anyone can check it.
// The section hides itself while this is empty.
type Testimonial = { quote: string; author: string; source: string; href: string };
const testimonials: Testimonial[] = [];
</script>
<template>
<section v-if="testimonials.length" class="mx-auto mt-20 max-w-[1152px] px-6">
<h2 class="m-0! text-center text-2xl font-semibold text-(--vp-c-text-1) md:text-3xl">What people say</h2>
<div class="mt-10 grid gap-4 md:grid-cols-3">
<figure
v-for="testimonial in testimonials"
:key="testimonial.href"
class="m-0! flex flex-col rounded-xl border border-solid border-(--vp-c-divider) bg-(--vp-c-bg-alt) p-6"
>
<Icon icon="mdi:format-quote-close" class="size-6 text-(--vp-c-text-3) opacity-60" />
<blockquote class="mt-3! mb-0! border-0! p-0! text-(--vp-c-text-1)">{{ testimonial.quote }}</blockquote>
<figcaption class="mt-4! text-sm text-(--vp-c-text-2)">
{{ testimonial.author }} on
<a :href="testimonial.href">{{ testimonial.source }}</a>
</figcaption>
</figure>
</div>
</section>
</template>
@@ -0,0 +1,63 @@
<script lang="ts" setup>
const rows = [
{
cli: "One container at a time, per host.",
dozzle: "Follow many containers side by side, merged across every host you connect.",
},
{
cli: "Pipe to grep and start over when you get it wrong.",
dozzle: "Search, filter by level, and jump to a timestamp while the logs keep streaming.",
},
{
cli: "JSON and stack traces arrive as a wall of text.",
dozzle: "Structured logs get parsed into fields, multi-line traces stay grouped.",
},
{
cli: "Separate docker stats window for CPU and memory.",
dozzle: "Live CPU, memory, and network charts on the same screen as the logs.",
},
{
cli: "SSH into each machine to look around.",
dozzle: "One browser tab over Docker, Swarm, and Kubernetes, with a shell when you need it.",
},
];
</script>
<template>
<section class="mx-auto mt-20 max-w-[1152px] px-6">
<h2 class="m-0! text-center text-2xl font-semibold text-(--vp-c-text-1) md:text-3xl">
Why not just <code class="bg-transparent! p-0! font-mono">docker logs</code>?
</h2>
<p class="mx-auto mt-3! mb-0! max-w-2xl text-center text-(--vp-c-text-2)">
It works fine until you are tailing three containers on two hosts at 2am.
</p>
<div class="mt-10 grid gap-4 md:grid-cols-2">
<div class="rounded-xl border border-solid border-(--vp-c-divider) bg-(--vp-c-bg-alt) p-6">
<div class="flex items-center gap-2 text-sm font-semibold tracking-wide text-(--vp-c-text-3) uppercase">
<Icon icon="mdi:console" class="size-4" />
docker logs
</div>
<ul class="mt-4! mb-0! flex list-none flex-col gap-3 p-0!">
<li v-for="row in rows" :key="row.cli" class="flex gap-2.5 text-(--vp-c-text-2)">
<Icon icon="mdi:close-circle-outline" class="mt-1 size-4 shrink-0 text-(--vp-c-text-3)" />
<span>{{ row.cli }}</span>
</li>
</ul>
</div>
<div class="rounded-xl border border-solid border-(--vp-c-brand-dimm) bg-(--vp-c-brand-dimm) p-6">
<div class="flex items-center gap-2 text-sm font-semibold tracking-wide text-(--vp-c-brand) uppercase">
<Icon icon="mdi:television-play" class="size-4" />
Dozzle
</div>
<ul class="mt-4! mb-0! flex list-none flex-col gap-3 p-0!">
<li v-for="row in rows" :key="row.dozzle" class="flex gap-2.5 text-(--vp-c-text-1)">
<Icon icon="mdi:check-circle-outline" class="mt-1 size-4 shrink-0 text-(--vp-c-brand)" />
<span>{{ row.dozzle }}</span>
</li>
</ul>
</div>
</div>
</section>
</template>
+6 -2
View File
@@ -8,8 +8,12 @@ import "./style.css";
import HeroVideo from "./components/HeroVideo.vue"; import HeroVideo from "./components/HeroVideo.vue";
import BuyMeCoffee from "./components/BuyMeCoffee.vue"; import BuyMeCoffee from "./components/BuyMeCoffee.vue";
import Stats from "./components/Stats.vue"; import Stats from "./components/Stats.vue";
import HeroTrust from "./components/HeroTrust.vue";
import Supported from "./components/Supported.vue"; import Supported from "./components/Supported.vue";
import InstallCommand from "./components/InstallCommand.vue"; import InstallCommand from "./components/InstallCommand.vue";
import WhyDozzle from "./components/WhyDozzle.vue";
import Testimonials from "./components/Testimonials.vue";
import FinalCta from "./components/FinalCta.vue";
import SponsoredBy from "./components/SponsoredBy.vue"; import SponsoredBy from "./components/SponsoredBy.vue";
export default { export default {
@@ -18,9 +22,9 @@ export default {
return h(DefaultTheme.Layout, null, { return h(DefaultTheme.Layout, null, {
"home-hero-image": () => h(HeroVideo), "home-hero-image": () => h(HeroVideo),
"sidebar-nav-after": () => h(BuyMeCoffee), "sidebar-nav-after": () => h(BuyMeCoffee),
"home-hero-actions-after": () => h(Stats), "home-hero-actions-after": () => [h(Stats), h(HeroTrust)],
"home-hero-after": () => [h(InstallCommand), h(Supported)], "home-hero-after": () => [h(InstallCommand), h(Supported)],
"home-features-after": () => h(SponsoredBy), "home-features-after": () => [h(WhyDozzle), h(Testimonials), h(FinalCta), h(SponsoredBy)],
}); });
}, },
enhanceApp(ctx) { enhanceApp(ctx) {
+4
View File
@@ -13,6 +13,8 @@ declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
BuyMeCoffee: typeof import('./.vitepress/theme/components/BuyMeCoffee.vue')['default'] BuyMeCoffee: typeof import('./.vitepress/theme/components/BuyMeCoffee.vue')['default']
Counter: typeof import('./.vitepress/theme/components/Counter.vue')['default'] Counter: typeof import('./.vitepress/theme/components/Counter.vue')['default']
FinalCta: typeof import('./.vitepress/theme/components/FinalCta.vue')['default']
HeroTrust: typeof import('./.vitepress/theme/components/HeroTrust.vue')['default']
HeroVideo: typeof import('./.vitepress/theme/components/HeroVideo.vue')['default'] HeroVideo: typeof import('./.vitepress/theme/components/HeroVideo.vue')['default']
InstallCommand: typeof import('./.vitepress/theme/components/InstallCommand.vue')['default'] InstallCommand: typeof import('./.vitepress/theme/components/InstallCommand.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
@@ -20,5 +22,7 @@ declare module 'vue' {
SponsoredBy: typeof import('./.vitepress/theme/components/SponsoredBy.vue')['default'] SponsoredBy: typeof import('./.vitepress/theme/components/SponsoredBy.vue')['default']
Stats: typeof import('./.vitepress/theme/components/Stats.vue')['default'] Stats: typeof import('./.vitepress/theme/components/Stats.vue')['default']
Supported: typeof import('./.vitepress/theme/components/Supported.vue')['default'] Supported: typeof import('./.vitepress/theme/components/Supported.vue')['default']
Testimonials: typeof import('./.vitepress/theme/components/Testimonials.vue')['default']
WhyDozzle: typeof import('./.vitepress/theme/components/WhyDozzle.vue')['default']
} }
} }