mirror of
https://github.com/amir20/dozzle.git
synced 2026-08-07 08:54:45 +00:00
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:
@@ -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 };
|
||||
|
||||
// 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 {
|
||||
async load() {
|
||||
const urls = [
|
||||
@@ -8,13 +28,18 @@ export default {
|
||||
"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 = {
|
||||
stars: responses[0].stargazers_count,
|
||||
pulls: responses[1].pull_count,
|
||||
return {
|
||||
stars: repo.stargazers_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 { withBase } from "vitepress";
|
||||
|
||||
const { hint = true, dense = false } = defineProps<{
|
||||
hint?: boolean;
|
||||
dense?: boolean;
|
||||
}>();
|
||||
|
||||
const command =
|
||||
"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>
|
||||
|
||||
<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
|
||||
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" }}
|
||||
</button>
|
||||
</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
|
||||
<a :href="withBase('/guide/getting-started')">install guide</a>.
|
||||
</p>
|
||||
|
||||
@@ -1,24 +1,49 @@
|
||||
<template>
|
||||
<div class="stats stats-horizontal mt-10">
|
||||
<a class="stat w-40 border-0! no-underline!" href="https://github.com/amir20/dozzle/stargazers">
|
||||
<div class="stat-title text-(--vp-c-text-1)">Github Stars</div>
|
||||
<div class="stat-value">
|
||||
<Counter :start="0" :end="data.stars" :duration="1000" :formatter="compact" />
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="stat border-0! no-underline!" href="https://hub.docker.com/r/amir20/dozzle">
|
||||
<div class="stat-title text-(--vp-c-text-1)">Docker Pulls</div>
|
||||
<div class="stat-value">
|
||||
<Counter :start="0" :end="data.pulls" :duration="1000" :formatter="compact" />
|
||||
</div>
|
||||
<div class="mt-10 flex flex-wrap gap-x-14 gap-y-8">
|
||||
<a
|
||||
v-for="stat in stats"
|
||||
:key="stat.label"
|
||||
class="flex flex-col no-underline! transition-colors hover:text-(--vp-c-brand)"
|
||||
:href="stat.href"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-sm text-(--vp-c-text-2)">
|
||||
<Icon :icon="stat.icon" class="size-4 opacity-70" />
|
||||
{{ stat.label }}
|
||||
</span>
|
||||
<span class="mt-2.5 text-4xl leading-none font-semibold tracking-tight text-(--vp-c-text-1) tabular-nums">
|
||||
<Counter :start="0" :end="stat.value" :duration="1000" :formatter="compact" />
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import { data } from "../../activity.data";
|
||||
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) => {
|
||||
if (value >= 1e9) return (value / 1e9).toFixed(1) + "B";
|
||||
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>
|
||||
@@ -8,8 +8,12 @@ import "./style.css";
|
||||
import HeroVideo from "./components/HeroVideo.vue";
|
||||
import BuyMeCoffee from "./components/BuyMeCoffee.vue";
|
||||
import Stats from "./components/Stats.vue";
|
||||
import HeroTrust from "./components/HeroTrust.vue";
|
||||
import Supported from "./components/Supported.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";
|
||||
|
||||
export default {
|
||||
@@ -18,9 +22,9 @@ export default {
|
||||
return h(DefaultTheme.Layout, null, {
|
||||
"home-hero-image": () => h(HeroVideo),
|
||||
"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-features-after": () => h(SponsoredBy),
|
||||
"home-features-after": () => [h(WhyDozzle), h(Testimonials), h(FinalCta), h(SponsoredBy)],
|
||||
});
|
||||
},
|
||||
enhanceApp(ctx) {
|
||||
|
||||
Vendored
+4
@@ -13,6 +13,8 @@ declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
BuyMeCoffee: typeof import('./.vitepress/theme/components/BuyMeCoffee.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']
|
||||
InstallCommand: typeof import('./.vitepress/theme/components/InstallCommand.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
@@ -20,5 +22,7 @@ declare module 'vue' {
|
||||
SponsoredBy: typeof import('./.vitepress/theme/components/SponsoredBy.vue')['default']
|
||||
Stats: typeof import('./.vitepress/theme/components/Stats.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']
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user