feat: implement download center navigation (#234)

This commit is contained in:
shenlan 2025-09-16 08:34:27 +08:00 committed by GitHub
parent 20788fb6b4
commit f80e8627dc
7 changed files with 611 additions and 131 deletions

View File

@ -1,50 +1,162 @@
import CardGrid from '../../../../components/download/CardGrid'
import Breadcrumbs, { Crumb } from '../../../../components/download/Breadcrumbs'
import FileTable from '../../../../components/download/FileTable'
import type { DirListing } from '../../../../types/download'
import type { Crumb } from '../../../../components/download/Breadcrumbs'
import { formatDate } from '../../../../lib/format'
import {
buildSectionsForListing,
countFiles,
findListing,
formatSegmentLabel,
} from '../../../../lib/download-data'
import listings from '../../../../public/dl-index/all.json'
import type { DirListing } from '../../../../types/download'
export async function generateStaticParams() {
return (listings as DirListing[])
.filter((l) => l.path)
.map((l) => {
const segs = l.path.split('/').filter(Boolean)
return { name: segs[0], path: segs.slice(1) }
const allListings = listings as DirListing[]
export function generateStaticParams() {
return allListings
.filter((listing) => listing.path)
.map((listing) => {
const segments = listing.path.split('/').filter(Boolean)
return { name: segments[0], path: segments.slice(1) }
})
}
export const dynamicParams = false
export const dynamic = 'force-static'
function buildBreadcrumb(segments: string[]): Crumb[] {
const items: Crumb[] = [{ label: 'Download', href: '/download' }]
segments.forEach((segment, index) => {
items.push({
label: formatSegmentLabel(segment),
href: '/download/' + segments.slice(0, index + 1).join('/'),
})
})
return items
}
function getLatestModified(listing: DirListing): string | undefined {
let latest: string | undefined
for (const entry of listing.entries) {
if (entry.lastModified && (!latest || new Date(entry.lastModified).getTime() > new Date(latest).getTime())) {
latest = entry.lastModified
}
}
return latest
}
export default function DownloadListing({
params,
}: {
params: { name: string; path?: string[] }
}) {
const { name, path = [] } = params
const segs = [name, ...path]
const fullPath = segs.join('/') + '/'
const dir = (listings as DirListing[]).find((l) => l.path === fullPath)
if (!dir) {
const segments = [name, ...path]
const listing = findListing(allListings, segments)
if (!listing) {
return (
<main className="p-4">
<div className="text-center text-red-500">Directory not found.</div>
<main className="px-4 py-8 md:px-8">
<div className="mx-auto max-w-3xl rounded-2xl border border-dashed p-10 text-center text-sm text-red-500">
Directory not found.
</div>
</main>
)
}
const breadcrumb: Crumb[] = segs.map((seg, idx) => ({
label: seg,
href: '/download/' + segs.slice(0, idx + 1).join('/'),
}))
const breadcrumb = buildBreadcrumb(segments)
const subdirectorySections = buildSectionsForListing(listing, allListings, segments)
const fileEntries = listing.entries.filter((entry) => entry.type === 'file')
const fileListing: DirListing = { path: listing.path, entries: fileEntries }
const totalFiles = countFiles(listing, allListings)
const latestModified = getLatestModified(listing)
const displayTitle = formatSegmentLabel(segments[segments.length - 1] ?? name)
const relativePath = segments.join('/')
const remotePath = `https://dl.svc.plus/${listing.path}`
return (
<main className="p-4 max-w-6xl mx-auto">
<FileTable listing={dir} breadcrumb={breadcrumb} />
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl shadow p-4">
<h2 className="font-semibold mb-2">Meta</h2>
<p className="text-sm">Path: {dir.path}</p>
<p className="text-sm mt-2">from dl.svc.plus</p>
<main className="px-4 py-8 md:px-8">
<div className="mx-auto max-w-7xl space-y-6">
<Breadcrumbs items={breadcrumb} />
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<section className="space-y-6">
<div className="rounded-2xl border bg-white p-6 shadow-sm">
<h1 className="text-2xl font-bold text-gray-900">{displayTitle}</h1>
<p className="mt-2 text-sm text-gray-600">
Explore downloads and artifacts available under the {displayTitle} directory.
</p>
<dl className="mt-4 grid gap-4 text-xs sm:grid-cols-3">
<div>
<dt className="text-gray-500">Subdirectories</dt>
<dd className="mt-1 text-sm font-semibold text-gray-900">
{subdirectorySections.length.toLocaleString()}
</dd>
</div>
<div>
<dt className="text-gray-500">Files</dt>
<dd className="mt-1 text-sm font-semibold text-gray-900">{totalFiles.toLocaleString()}</dd>
</div>
{latestModified && (
<div>
<dt className="text-gray-500">Last updated</dt>
<dd className="mt-1 text-sm font-semibold text-gray-900">{formatDate(latestModified)}</dd>
</div>
)}
</dl>
</div>
{subdirectorySections.length > 0 && (
<div className="rounded-2xl border bg-white p-6 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Collections</h2>
<span className="text-xs text-gray-500">
{subdirectorySections.length.toLocaleString()} entr{subdirectorySections.length === 1 ? 'y' : 'ies'}
</span>
</div>
<CardGrid sections={subdirectorySections} />
</div>
)}
{fileListing.entries.length > 0 && (
<div className="rounded-2xl border bg-white p-4 shadow-sm">
<FileTable listing={fileListing} breadcrumb={breadcrumb} showBreadcrumbs={false} />
</div>
)}
{subdirectorySections.length === 0 && fileListing.entries.length === 0 && (
<div className="rounded-2xl border border-dashed p-10 text-center text-sm text-gray-500">
This directory does not contain downloadable artifacts yet.
</div>
)}
</section>
<aside className="space-y-4 lg:sticky lg:top-24">
<div className="rounded-2xl border bg-white p-6 shadow-sm">
<h2 className="text-sm font-semibold text-gray-700">Directory info</h2>
<dl className="mt-4 space-y-3 text-xs text-gray-600">
<div>
<dt className="text-gray-500">Path</dt>
<dd className="mt-1 font-mono text-sm text-gray-900">/{relativePath}</dd>
</div>
<div>
<dt className="text-gray-500">Source</dt>
<dd className="mt-1 text-sm">
<a
href={remotePath}
target="_blank"
rel="noopener noreferrer"
className="text-purple-600 hover:underline"
>
{remotePath}
</a>
</dd>
</div>
</dl>
<p className="mt-4 text-xs text-gray-500">Data sourced from dl.svc.plus.</p>
</div>
</aside>
</div>
</div>
</main>

View File

@ -1,34 +1,45 @@
import DownloadBrowser from '../../components/download/DownloadBrowser'
import { buildDownloadSections, countFiles, findListing } from '../../lib/download-data'
import listings from '../../public/dl-index/all.json'
import type { DirListing } from '../../types/download'
interface Section {
key: string
title: string
href: string
lastModified?: string
count?: number
root: string
}
export default function DownloadHome() {
const sectionsMap: Record<string, Section[]> = {}
const root = (listings as DirListing[]).find((l) => l.path === '')
if (root) {
for (const entry of root.entries.filter((e) => e.type === 'dir')) {
const child = (listings as DirListing[]).find((l) => l.path === `${entry.name}/`)
const secs: Section[] = (child?.entries || [])
.filter((e) => e.type === 'dir')
.map((e) => ({
key: `${entry.name}/${e.name}`,
title: e.name,
href: `/download/${entry.name}/${e.name}`,
lastModified: e.lastModified,
count: (e as any).count,
root: entry.name,
}))
sectionsMap[entry.name] = secs
}
}
return <DownloadBrowser sectionsMap={sectionsMap} />
const allListings = listings as DirListing[]
const sectionsMap = buildDownloadSections(allListings)
const rootListing = findListing(allListings, [])
const topLevelDirectories = rootListing?.entries.filter((entry) => entry.type === 'dir') ?? []
const totalCollections = Object.values(sectionsMap).reduce((total, sections) => total + sections.length, 0)
const totalFiles = topLevelDirectories.reduce((total, entry) => {
const listing = findListing(allListings, [entry.name])
return total + (listing ? countFiles(listing, allListings) : 0)
}, 0)
return (
<main className="px-4 py-8 md:px-8">
<div className="mx-auto max-w-7xl space-y-6">
<section className="rounded-2xl border bg-gradient-to-br from-purple-50 to-white p-6 shadow-sm md:p-8">
<h1 className="text-3xl font-bold">Download Center</h1>
<p className="mt-2 text-sm text-gray-600">
Browse offline packages, releases, and other curated resources hosted on dl.svc.plus.
</p>
<dl className="mt-6 grid gap-4 text-sm sm:grid-cols-3">
<div>
<dt className="text-gray-500">Top-level categories</dt>
<dd className="mt-1 text-xl font-semibold">{topLevelDirectories.length.toLocaleString()}</dd>
</div>
<div>
<dt className="text-gray-500">Resource collections</dt>
<dd className="mt-1 text-xl font-semibold">{totalCollections.toLocaleString()}</dd>
</div>
<div>
<dt className="text-gray-500">Files tracked</dt>
<dd className="mt-1 text-xl font-semibold">{totalFiles.toLocaleString()}</dd>
</div>
</dl>
</section>
<DownloadBrowser sectionsMap={sectionsMap} />
</div>
</main>
)
}

View File

@ -1,8 +1,9 @@
'use client'
import { useState, useMemo } from 'react'
import Link from 'next/link'
import { useMemo, useState } from 'react'
import { formatDate } from '../../lib/format'
import { formatSegmentLabel } from '../../lib/download-data'
interface Section {
key: string
@ -10,6 +11,7 @@ interface Section {
href: string
lastModified?: string
count?: number
root?: string
}
export default function CardGrid({ sections }: { sections: Section[] }) {
@ -18,22 +20,18 @@ export default function CardGrid({ sections }: { sections: Section[] }) {
const filtered = useMemo(() => {
return sections
.filter((s) => s.title.toLowerCase().includes(search.toLowerCase()))
.filter((section) => section.title.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) =>
sort === 'title'
? a.title.localeCompare(b.title)
: new Date(b.lastModified || 0).getTime() - new Date(a.lastModified || 0).getTime()
: new Date(b.lastModified || 0).getTime() - new Date(a.lastModified || 0).getTime(),
)
}, [sections, search, sort])
return (
<div>
<div className="sticky top-0 z-10 bg-white flex items-center mb-4 gap-2 border-b pb-2">
<select
className="border rounded p-2"
value={sort}
onChange={(e) => setSort(e.target.value as any)}
>
<div className="sticky top-20 z-10 mb-4 flex items-center gap-2 border-b bg-white pb-2">
<select className="rounded border p-2" value={sort} onChange={(event) => setSort(event.target.value as any)}>
<option value="lastModified">Sort by Updated</option>
<option value="title">Sort by Name</option>
</select>
@ -41,25 +39,30 @@ export default function CardGrid({ sections }: { sections: Section[] }) {
<input
placeholder="Search"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="border rounded p-2"
onChange={(event) => setSearch(event.target.value)}
className="rounded border p-2"
/>
</div>
</div>
<div className="columns-1 sm:columns-2 lg:columns-3 gap-4">
<div className="columns-1 gap-4 sm:columns-2 lg:columns-3">
{filtered.map((section) => (
<Link
key={section.key}
href={section.href}
className="mb-4 block break-inside-avoid border rounded p-4 hover:shadow"
className="mb-4 block break-inside-avoid rounded-2xl border bg-white p-5 shadow-sm transition hover:-translate-y-1 hover:shadow-lg"
>
<div className="text-4xl font-bold mb-2">
{section.title.charAt(0).toUpperCase()}
</div>
<div className="text-sm font-semibold mb-2">{section.title}</div>
<div className="text-xs text-gray-600">
{section.lastModified && <p>Updated: {formatDate(section.lastModified)}</p>}
{section.count !== undefined && <p>Items: {section.count}</p>}
<div className="flex flex-col gap-3">
{section.root && (
<span className="inline-flex w-fit items-center rounded-full bg-purple-50 px-2 py-0.5 text-xs font-semibold text-purple-600">
{formatSegmentLabel(section.root)}
</span>
)}
<div className="text-4xl font-bold text-gray-900">{section.title.charAt(0).toUpperCase()}</div>
<div className="text-base font-semibold text-gray-900">{section.title}</div>
<div className="space-y-1 text-xs text-gray-600">
{section.lastModified && <p>Updated: {formatDate(section.lastModified)}</p>}
{section.count !== undefined && <p>Items: {section.count.toLocaleString()}</p>}
</div>
</div>
</Link>
))}

View File

@ -1,55 +1,93 @@
"use client"
import { useState, useMemo } from 'react'
import { useMemo, useState } from 'react'
import { formatSegmentLabel, type DownloadSection } from '../../lib/download-data'
import CardGrid from './CardGrid'
interface Section {
key: string
title: string
href: string
lastModified?: string
count?: number
root: string
interface DownloadBrowserProps {
sectionsMap: Record<string, DownloadSection[]>
}
export default function DownloadBrowser({
sectionsMap,
}: {
sectionsMap: Record<string, Section[]>
}) {
export default function DownloadBrowser({ sectionsMap }: DownloadBrowserProps) {
const roots = useMemo(() => Object.keys(sectionsMap), [sectionsMap])
const [current, setCurrent] = useState<string>('all')
const roots = Object.keys(sectionsMap)
const allSections = useMemo(() => Object.values(sectionsMap).flat(), [sectionsMap])
const sections = current === 'all' ? allSections : sectionsMap[current] || []
const totalsByRoot = useMemo(() => {
const totals: Record<string, number> = {}
for (const root of roots) {
totals[root] = sectionsMap[root]?.length ?? 0
}
return totals
}, [roots, sectionsMap])
const allSections = useMemo(
() => roots.flatMap((root) => sectionsMap[root] ?? []),
[roots, sectionsMap],
)
const sections = current === 'all' ? allSections : sectionsMap[current] ?? []
const activeLabel = current === 'all' ? 'All downloads' : formatSegmentLabel(current)
const description =
current === 'all'
? 'Browse the complete catalog of offline packages, releases, and artifacts.'
: `Showing resources from the ${formatSegmentLabel(current)} collection.`
return (
<main className="flex">
<aside className="w-48 border-r p-4">
<ul className="space-y-2">
<li key="all">
<button
className={`text-left w-full ${current === 'all' ? 'font-semibold' : ''}`}
onClick={() => setCurrent('all')}
>
All
</button>
</li>
{roots.map((r) => (
<li key={r}>
<div className="flex flex-col gap-6 lg:flex-row">
<aside className="lg:w-64">
<div className="sticky top-24 rounded-2xl border bg-white p-4 shadow-sm">
<h2 className="text-sm font-semibold text-gray-700">Categories</h2>
<ul className="mt-4 space-y-1 text-sm">
<li key="all">
<button
className={`text-left w-full ${current === r ? 'font-semibold' : ''}`}
onClick={() => setCurrent(r)}
type="button"
onClick={() => setCurrent('all')}
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 transition-colors ${
current === 'all' ? 'bg-purple-100 text-purple-700' : 'hover:bg-gray-100'
}`}
>
{r}
<span>All resources</span>
<span className="text-xs text-gray-500">{allSections.length.toLocaleString()}</span>
</button>
</li>
))}
</ul>
{roots.map((root) => (
<li key={root}>
<button
type="button"
onClick={() => setCurrent(root)}
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 transition-colors ${
current === root ? 'bg-purple-100 text-purple-700' : 'hover:bg-gray-100'
}`}
>
<span>{formatSegmentLabel(root)}</span>
<span className="text-xs text-gray-500">{(totalsByRoot[root] ?? 0).toLocaleString()}</span>
</button>
</li>
))}
</ul>
</div>
</aside>
<div className="flex-1 p-4">
<CardGrid sections={sections} />
</div>
</main>
<section className="flex-1 space-y-6">
<div className="rounded-2xl border bg-white p-6 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-gray-900">{activeLabel}</h2>
<p className="mt-1 text-sm text-gray-600">{description}</p>
</div>
<span className="rounded-full bg-purple-50 px-3 py-1 text-xs font-medium text-purple-600">
{sections.length.toLocaleString()} item{sections.length === 1 ? '' : 's'}
</span>
</div>
</div>
{sections.length > 0 ? (
<CardGrid sections={sections} />
) : (
<div className="rounded-2xl border border-dashed p-10 text-center text-sm text-gray-500">
No downloadable resources found for this category yet.
</div>
)}
</section>
</div>
)
}

View File

@ -1,18 +1,24 @@
'use client'
import { useState, useMemo } from 'react'
import CopyButton from './CopyButton'
import { useMemo, useState } from 'react'
import Breadcrumbs, { Crumb } from './Breadcrumbs'
import CopyButton from './CopyButton'
import { formatBytes, formatDate } from '../../lib/format'
import type { DirListing } from '../../types/download'
export default function FileTable({ listing, breadcrumb }: { listing: DirListing; breadcrumb: Crumb[] }) {
interface FileTableProps {
listing: DirListing
breadcrumb: Crumb[]
showBreadcrumbs?: boolean
}
export default function FileTable({ listing, breadcrumb, showBreadcrumbs = true }: FileTableProps) {
const [sort, setSort] = useState<'name' | 'lastModified' | 'size'>('name')
const [ext, setExt] = useState('')
const filtered = useMemo(() => {
return listing.entries
.filter((i) => !ext || i.name.endsWith(ext))
.filter((item) => !ext || item.name.toLowerCase().endsWith(ext.toLowerCase()))
.sort((a, b) => {
switch (sort) {
case 'lastModified':
@ -27,27 +33,27 @@ export default function FileTable({ listing, breadcrumb }: { listing: DirListing
return (
<div>
<Breadcrumbs items={breadcrumb} />
<div className="flex gap-2 mb-2">
<select className="border rounded p-2" value={sort} onChange={(e) => setSort(e.target.value as any)}>
{showBreadcrumbs && <Breadcrumbs items={breadcrumb} />}
<div className="mb-2 flex flex-wrap gap-2">
<select className="rounded border p-2" value={sort} onChange={(event) => setSort(event.target.value as any)}>
<option value="name">Name</option>
<option value="lastModified">Updated</option>
<option value="size">Size</option>
</select>
<input
className="border rounded p-2"
className="rounded border p-2"
placeholder="Filter ext (.tar.gz)"
value={ext}
onChange={(e) => setExt(e.target.value)}
onChange={(event) => setExt(event.target.value)}
/>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2">Name</th>
<th className="text-left py-2 w-24">Size</th>
<th className="text-left py-2 w-48">Updated</th>
<th className="text-left py-2 w-32">Actions</th>
<th className="py-2 text-left">Name</th>
<th className="w-24 py-2 text-left">Size</th>
<th className="w-48 py-2 text-left">Updated</th>
<th className="w-40 py-2 text-left">Actions</th>
</tr>
</thead>
<tbody>
@ -59,13 +65,15 @@ export default function FileTable({ listing, breadcrumb }: { listing: DirListing
</a>
</td>
<td className="py-1">{formatBytes(item.size || 0)}</td>
<td className="py-1">{formatDate(item.lastModified || '')}</td>
<td className="py-1 flex gap-2">
<CopyButton text={`https://dl.svc.plus${item.href}`} />
<CopyButton text={`wget -c https://dl.svc.plus${item.href}`} />
{item.sha256 && (
<CopyButton text={`wget -O - https://dl.svc.plus${item.sha256} | grep ${item.name} | sha256sum -c -`} />
)}
<td className="py-1">{item.lastModified ? formatDate(item.lastModified) : '--'}</td>
<td className="py-1">
<div className="flex flex-wrap gap-2">
<CopyButton text={`https://dl.svc.plus${item.href}`} />
<CopyButton text={`wget -c https://dl.svc.plus${item.href}`} />
{item.sha256 && (
<CopyButton text={`wget -O - https://dl.svc.plus${item.sha256} | grep ${item.name} | sha256sum -c -`} />
)}
</div>
</td>
</tr>
))}

View File

@ -0,0 +1,119 @@
import type { DirListing } from '../types/download'
export interface DownloadSection {
key: string
title: string
href: string
lastModified?: string
count?: number
root: string
}
export function formatSegmentLabel(segment: string): string {
return segment
.split(/[-_]/g)
.filter(Boolean)
.map((part) => (part.match(/^[a-z]+$/) ? part.charAt(0).toUpperCase() + part.slice(1) : part))
.join(' ') || segment
}
export function findListing(allListings: DirListing[], segments: string[]): DirListing | undefined {
const normalized = segments.filter((segment) => segment.length > 0).join('/')
const key = normalized ? `${normalized}/` : ''
return allListings.find((listing) => listing.path === key)
}
export function countFiles(listing: DirListing, allListings: DirListing[]): number {
const baseSegments = listing.path.split('/').filter(Boolean)
return listing.entries.reduce((total, entry) => {
if (entry.type === 'file') {
return total + 1
}
if (entry.type === 'dir') {
const child = findListing(allListings, [...baseSegments, entry.name])
if (child) {
return total + countFiles(child, allListings)
}
}
return total
}, 0)
}
export function buildSectionsForListing(
listing: DirListing,
allListings: DirListing[],
baseSegments: string[],
): DownloadSection[] {
return listing.entries
.filter((entry) => entry.type === 'dir')
.map((entry) => {
const segments = [...baseSegments, entry.name]
const childListing = findListing(allListings, segments)
return {
key: segments.join('/'),
title: formatSegmentLabel(entry.name),
href: `/download/${segments.join('/')}`,
lastModified: entry.lastModified,
count: childListing ? countFiles(childListing, allListings) : undefined,
root: baseSegments[0] ?? entry.name,
}
})
}
export function buildDownloadSections(allListings: DirListing[]): Record<string, DownloadSection[]> {
const rootListing = findListing(allListings, [])
if (!rootListing) {
return {}
}
const sectionsMap: Record<string, DownloadSection[]> = {}
for (const entry of rootListing.entries) {
if (entry.type !== 'dir') continue
const rootSegments = [entry.name]
const listing = findListing(allListings, rootSegments)
if (!listing) {
sectionsMap[entry.name] = [
{
key: rootSegments.join('/'),
title: formatSegmentLabel(entry.name),
href: `/download/${rootSegments.join('/')}`,
lastModified: entry.lastModified,
root: entry.name,
},
]
continue
}
const childSections = buildSectionsForListing(listing, allListings, rootSegments)
const hasFiles = listing.entries.some((item) => item.type === 'file')
if (childSections.length > 0) {
sectionsMap[entry.name] = hasFiles
? [
{
key: rootSegments.join('/'),
title: formatSegmentLabel(entry.name),
href: `/download/${rootSegments.join('/')}`,
lastModified: entry.lastModified,
count: countFiles(listing, allListings),
root: entry.name,
},
...childSections,
]
: childSections;
} else {
sectionsMap[entry.name] = [
{
key: rootSegments.join('/'),
title: formatSegmentLabel(entry.name),
href: `/download/${rootSegments.join('/')}`,
lastModified: entry.lastModified,
count: countFiles(listing, allListings),
root: entry.name,
},
]
}
}
return sectionsMap
}

View File

@ -0,0 +1,189 @@
[
{
"path": "",
"entries": [
{
"name": "offline-package",
"href": "/offline-package/",
"type": "dir",
"lastModified": "2024-07-01T12:00:00Z"
},
{
"name": "utilities",
"href": "/utilities/",
"type": "dir",
"lastModified": "2024-06-15T10:00:00Z"
},
{
"name": "docs",
"href": "/docs/",
"type": "dir",
"lastModified": "2024-05-10T08:00:00Z"
}
]
},
{
"path": "offline-package/",
"entries": [
{
"name": "k3s",
"href": "/offline-package/k3s/",
"type": "dir",
"lastModified": "2024-07-01T12:00:00Z"
},
{
"name": "rke2",
"href": "/offline-package/rke2/",
"type": "dir",
"lastModified": "2024-06-20T09:00:00Z"
},
{
"name": "bundle-readme.md",
"href": "/offline-package/bundle-readme.md",
"type": "file",
"size": 2048,
"lastModified": "2024-05-01T00:00:00Z"
}
]
},
{
"path": "offline-package/k3s/",
"entries": [
{
"name": "v1.28.0",
"href": "/offline-package/k3s/v1.28.0/",
"type": "dir",
"lastModified": "2024-07-01T12:00:00Z"
},
{
"name": "v1.27.0",
"href": "/offline-package/k3s/v1.27.0/",
"type": "dir",
"lastModified": "2024-06-10T12:00:00Z"
},
{
"name": "k3s-offline.tar.gz",
"href": "/offline-package/k3s/k3s-offline.tar.gz",
"type": "file",
"size": 524288000,
"lastModified": "2024-07-01T12:00:00Z",
"sha256": "/offline-package/k3s/sha256sum.txt"
}
]
},
{
"path": "offline-package/k3s/v1.28.0/",
"entries": [
{
"name": "k3s-linux-amd64.tar.gz",
"href": "/offline-package/k3s/v1.28.0/k3s-linux-amd64.tar.gz",
"type": "file",
"size": 104857600,
"lastModified": "2024-07-01T12:00:00Z",
"sha256": "/offline-package/k3s/v1.28.0/sha256sum.txt"
},
{
"name": "k3s-windows.zip",
"href": "/offline-package/k3s/v1.28.0/k3s-windows.zip",
"type": "file",
"size": 209715200,
"lastModified": "2024-07-01T12:00:00Z"
}
]
},
{
"path": "offline-package/k3s/v1.27.0/",
"entries": [
{
"name": "k3s-linux-amd64.tar.gz",
"href": "/offline-package/k3s/v1.27.0/k3s-linux-amd64.tar.gz",
"type": "file",
"size": 104857600,
"lastModified": "2024-06-10T12:00:00Z"
}
]
},
{
"path": "offline-package/rke2/",
"entries": [
{
"name": "v1.27.0",
"href": "/offline-package/rke2/v1.27.0/",
"type": "dir",
"lastModified": "2024-06-20T09:00:00Z"
}
]
},
{
"path": "offline-package/rke2/v1.27.0/",
"entries": [
{
"name": "rke2-linux-amd64.tar.gz",
"href": "/offline-package/rke2/v1.27.0/rke2-linux-amd64.tar.gz",
"type": "file",
"size": 157286400,
"lastModified": "2024-06-20T09:00:00Z"
}
]
},
{
"path": "utilities/",
"entries": [
{
"name": "cluster-toolkit",
"href": "/utilities/cluster-toolkit/",
"type": "dir",
"lastModified": "2024-05-30T10:00:00Z"
},
{
"name": "diagnostic-bundle.zip",
"href": "/utilities/diagnostic-bundle.zip",
"type": "file",
"size": 31457280,
"lastModified": "2024-05-18T15:00:00Z"
}
]
},
{
"path": "utilities/cluster-toolkit/",
"entries": [
{
"name": "README.md",
"href": "/utilities/cluster-toolkit/README.md",
"type": "file",
"size": 4096,
"lastModified": "2024-05-30T10:00:00Z"
},
{
"name": "cluster-toolkit-linux.tar.gz",
"href": "/utilities/cluster-toolkit/cluster-toolkit-linux.tar.gz",
"type": "file",
"size": 52428800,
"lastModified": "2024-05-30T10:00:00Z"
}
]
},
{
"path": "docs/",
"entries": [
{
"name": "k3s",
"href": "/docs/k3s/",
"type": "dir",
"lastModified": "2024-06-01T00:00:00Z"
}
]
},
{
"path": "docs/k3s/",
"entries": [
{
"name": "getting-started.md",
"href": "/docs/k3s/getting-started.md",
"type": "file",
"size": 8192,
"lastModified": "2024-06-01T00:00:00Z"
}
]
}
]