First commit

This commit is contained in:
Alexis Métaireau 2024-11-11 00:06:35 +01:00
commit 2fbafed46e
No known key found for this signature in database
GPG key ID: C65C7A89A8FFC56E
8 changed files with 643 additions and 0 deletions

76
generate_stats.py Normal file
View file

@ -0,0 +1,76 @@
#!/usr/bin/env -S uv run
# Needs "uv" to be run. chmod +x and then ./generate_stats.py
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "jinja2", "httpx"
# ]
# ///
import os
import json
from pathlib import Path
from datetime import datetime
from jinja2 import Environment, FileSystemLoader
import httpx
from typing import Dict, List, TypedDict
class ReleaseStats(TypedDict):
releases: List[Dict]
async def fetch_github_stats(repo: str) -> ReleaseStats:
"""Fetch GitHub release statistics"""
async with httpx.AsyncClient() as client:
# Fetch releases with download counts
releases_resp = await client.get(
f"https://api.github.com/repos/{repo}/releases"
)
releases = releases_resp.json()
# Process releases to include download counts per asset
processed_releases = []
for release in releases:
processed_releases.append({
"name": release["name"] or release["tag_name"],
"published_at": release["published_at"],
"html_url": release["html_url"],
"assets": [{
"name": asset["name"],
"download_count": asset["download_count"],
"download_url": asset["browser_download_url"]
} for asset in release["assets"]]
})
return {
"releases": processed_releases
}
def generate_site(stats: ReleaseStats) -> None:
"""Generate the static site using the templates"""
output_dir = Path("output")
output_dir.mkdir(exist_ok=True)
templates_dir = Path("templates")
templates_dir.mkdir(exist_ok=True)
env = Environment(loader=FileSystemLoader("templates"))
template = env.get_template("index.html")
index_content = template.render(
stats=stats,
generated_at=datetime.now().isoformat()
)
with open(output_dir / "index.html", "w") as f:
f.write(index_content)
with open(output_dir / "stats.json", "w") as f:
json.dump(stats, f)
async def main():
repo = os.getenv("GITHUB_REPO", "freedomofpress/dangerzone")
stats = await fetch_github_stats(repo)
generate_site(stats)
if __name__ == "__main__":
import asyncio
asyncio.run(main())

8
output/app.js Normal file
View file

@ -0,0 +1,8 @@
import { h, render } from 'https://esm.sh/preact';
import { useState } from 'https://esm.sh/preact/hooks';
import { App } from './components/App.js';
const stats = window.INITIAL_STATS;
const generatedAt = window.GENERATED_AT;
render(h(App, { stats, generatedAt }), document.getElementById('app'));

28
output/components/App.js Normal file
View file

@ -0,0 +1,28 @@
import { h } from 'https://esm.sh/preact';
import { useState } from 'https://esm.sh/preact/hooks';
import { Stats } from './Stats.js';
import { Chart } from './Chart.js';
export function App({ stats, generatedAt }) {
const [activeTab, setActiveTab] = useState('charts');
return h('div', { class: 'container' }, [
h('header', { class: 'header' }, [
h('h1', null, 'Dangerzone Release Stats'),
h('p', null, `Generated at: ${new Date(generatedAt).toLocaleString()}`)
]),
h('nav', { class: 'tabs' }, [
h('button', {
class: activeTab === 'charts' ? 'active' : '',
onClick: () => setActiveTab('charts')
}, 'Charts'),
h('button', {
class: activeTab === 'overview' ? 'active' : '',
onClick: () => setActiveTab('overview')
}, 'Overview')
]),
activeTab === 'charts'
? h(Chart, { stats })
: h(Stats, { stats })
]);
}

113
output/components/Chart.js vendored Normal file
View file

@ -0,0 +1,113 @@
import { h } from 'https://esm.sh/preact';
function getPlatform(assetName) {
assetName = assetName.toLowerCase();
if (assetName.includes('msi')) return 'Windows';
if (assetName.includes('arm64.dmg')) return 'Mac Silicon';
if (assetName.includes('i686.dmg') || assetName.includes('.dmg')) return 'Mac Intel';
if (assetName.includes('container')) return 'Container';
return 'Other';
}
function getColorForPlatform(platform) {
switch (platform) {
case 'Windows': return '#00A4EF';
case 'Mac Intel': return '#A2AAAD';
case 'Mac Silicon': return '#C4C4C4';
case 'Container': return '#FFD700';
default: return '#FF69B4';
}
}
function formatDate(dateString) {
return new Date(dateString).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short'
});
}
export function Chart({ stats }) {
console.log('Received stats:', stats);
if (!stats || !stats.releases || !Array.isArray(stats.releases)) {
return h('div', { class: 'charts' }, 'No data available');
}
const platforms = ['Windows', 'Mac Intel', 'Mac Silicon', 'Container', 'Other'];
const downloadsByRelease = stats.releases
.filter(release => release && release.name && release.published_at)
.map(release => {
console.log('Processing release:', release);
return {
name: (release.name || '').replace(/^Dangerzone /, ''),
date: release.published_at,
platforms: platforms.reduce((acc, platform) => {
acc[platform] = 0;
return acc;
}, {}),
total: 0,
assets: release.assets || []
};
})
.map(release => {
release.assets.forEach(asset => {
const platform = getPlatform(asset.name);
release.platforms[platform] += asset.download_count || 0;
});
release.total = Object.values(release.platforms).reduce((a, b) => a + b, 0);
return release;
})
.sort((a, b) => new Date(b.date) - new Date(a.date));
console.log('Processed releases:', downloadsByRelease);
if (downloadsByRelease.length === 0) {
return h('div', { class: 'charts' }, 'No release data available');
}
const maxDownloads = Math.max(...downloadsByRelease.map(r => r.total));
return h('div', { class: 'charts' }, [
h('div', { class: 'chart horizontal' }, [
h('div', { class: 'chart-legend' },
platforms.map(platform =>
h('div', { class: 'legend-item' }, [
h('span', {
class: 'legend-color',
style: `background-color: ${getColorForPlatform(platform)}`
}),
h('span', { class: 'legend-label' }, platform)
])
)
),
h('div', { class: 'chart-container horizontal' },
downloadsByRelease.map(release =>
h('div', { class: 'bar-group horizontal' }, [
h('div', { class: 'bar-label horizontal' }, [
h('span', { class: 'version-label' }, release.name),
h('span', { class: 'date-label' }, formatDate(release.date))
]),
h('div', { class: 'stacked-bars horizontal' },
platforms.map(platform => {
const width = (release.platforms[platform] / maxDownloads) * 400;
return h('div', {
class: 'bar stacked-bar horizontal',
style: `
width: ${width}px;
background-color: ${getColorForPlatform(platform)};
`,
title: `${platform}: ${release.platforms[platform].toLocaleString()} downloads`
});
})
),
h('div', { class: 'total-downloads' },
`${release.total.toLocaleString()} downloads`
)
])
)
)
])
]);
}

View file

@ -0,0 +1,30 @@
import { h } from 'https://esm.sh/preact';
export function Stats({ stats }) {
return h('div', { class: 'stats-grid' }, [
...stats.releases.map(release =>
h('div', { class: 'stat-card' }, [
h('h2', null, [
h('a', { href: release.html_url, target: '_blank' }, release.name)
]),
h('p', { class: 'release-date' },
new Date(release.published_at).toLocaleDateString()
),
h('div', { class: 'assets-list' },
release.assets.map(asset =>
h('div', { class: 'asset-item' }, [
h('a', {
href: asset.download_url,
class: 'asset-name',
target: '_blank'
}, asset.name),
h('span', { class: 'download-count' },
`${asset.download_count.toLocaleString()} downloads`
)
])
)
)
])
)
]);
}

1
output/stats.json Normal file

File diff suppressed because one or more lines are too long

367
output/styles.css Normal file
View file

@ -0,0 +1,367 @@
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f8fa;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
text-align: center;
margin-bottom: 2rem;
}
.header h1 {
color: #24292e;
margin-bottom: 0.5rem;
}
.tabs {
display: flex;
justify-content: center;
margin-bottom: 2rem;
}
.tabs button {
background: none;
border: none;
padding: 0.5rem 1rem;
margin: 0 0.5rem;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.tabs button.active {
border-bottom-color: #0366d6;
color: #0366d6;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1rem;
}
.stat-card {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
}
.stat-card h2 {
margin-top: 0;
color: #24292e;
}
.stat-card ul {
list-style: none;
padding: 0;
}
.stat-card li {
margin-bottom: 0.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.stat-card a {
color: #0366d6;
text-decoration: none;
}
.stat-card a:hover {
text-decoration: underline;
}
.state {
padding: 2px 6px;
border-radius: 12px;
font-size: 0.8rem;
}
.state.open {
background: #28a745;
color: white;
}
.state.closed {
background: #d73a49;
color: white;
}
.charts {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
overflow: hidden;
}
.chart {
margin-bottom: 2rem;
}
.chart h3 {
margin-top: 0;
color: #24292e;
}
.chart-container {
display: flex;
align-items: flex-end;
height: 250px;
padding: 1rem;
gap: 1rem;
min-width: 100%;
overflow-x: auto;
}
.chart-container.stacked {
position: relative;
margin-top: 2rem;
gap: 0.5rem;
}
.chart-legend {
position: sticky;
top: 0;
z-index: 2;
margin: 0;
padding: 1rem;
background: white;
border-radius: 6px;
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 1rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.legend-color {
width: 16px;
height: 16px;
border-radius: 3px;
}
.bar-group {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.bar-group.stacked {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
min-width: 45px;
}
.stacked-bars {
display: flex;
flex-direction: column-reverse;
width: 35px;
}
.bar {
width: 30px;
background: #0366d6;
transition: height 0.3s ease;
}
.bar.stacked-bar {
width: 100%;
transition: height 0.3s ease;
}
.bar.stacked-bar:hover {
opacity: 0.8;
}
.bar-label {
margin-top: 1rem;
font-size: 0.75rem;
text-align: center;
transform: rotate(-45deg);
transform-origin: top center;
white-space: nowrap;
display: flex;
flex-direction: column;
gap: 2px;
}
.pr-bar {
background: #0366d6;
}
.issue-bar {
background: #28a745;
}
.release-date {
color: #666;
font-size: 0.9rem;
margin: 0.5rem 0;
}
.assets-list {
margin-top: 1rem;
}
.asset-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid #eee;
}
.asset-item:last-child {
border-bottom: none;
}
.asset-name {
color: #0366d6;
text-decoration: none;
font-size: 0.9rem;
}
.asset-name:hover {
text-decoration: underline;
}
.download-count {
font-size: 0.8rem;
color: #666;
background: #f1f8ff;
padding: 2px 8px;
border-radius: 12px;
}
.download-bar {
background: #28a745;
}
.download-total {
display: block;
font-size: 0.7rem;
color: #666;
margin-top: 2px;
}
.version-label {
font-weight: 500;
}
.date-label {
font-size: 0.7rem;
color: #666;
}
.chart.horizontal {
margin: 2rem 0;
min-height: 500px;
}
.chart-container.horizontal {
flex-direction: column;
align-items: stretch;
height: auto;
min-height: 400px;
padding: calc(1rem + 70px) 2rem 1rem 12rem;
gap: 1.5rem;
overflow-y: auto;
position: relative;
}
.bar-group.horizontal {
flex-direction: row;
align-items: center;
width: 100%;
height: 30px;
position: relative;
margin: 0.5rem 0;
display: flex;
border-bottom: 1px solid #eee;
}
.bar-group.horizontal:last-child {
border-bottom: none;
}
.bar-label.horizontal {
position: absolute;
left: -11rem;
width: 10rem;
transform: none;
margin: 0;
text-align: right;
padding-right: 1rem;
z-index: 1;
}
.stacked-bars.horizontal {
display: flex;
flex-direction: row;
width: 400px;
height: 30px;
position: relative;
background: transparent;
}
.bar.stacked-bar.horizontal {
height: 30px;
display: inline-block;
transition: width 0.3s ease;
}
.bar.stacked-bar.horizontal+.bar.stacked-bar.horizontal {
margin-left: 0;
}
.download-total.horizontal {
position: absolute;
left: calc(100% + 10px);
top: 50%;
transform: translateY(-50%);
white-space: nowrap;
font-size: 0.8rem;
color: #666;
padding-left: 1rem;
}
.version-label {
display: block;
font-weight: 500;
font-size: 0.85rem;
color: #24292e;
}
.date-label {
display: block;
font-size: 0.75rem;
color: #666;
}
.total-downloads {
margin-left: 1rem;
font-size: 0.9rem;
color: #94a2b0;
white-space: nowrap;
min-width: 100px;
padding-left: 1rem;
}

20
templates/index.html Normal file
View file

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dangerzone Release Stats</title>
<script type="module" src="app.js"></script>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="app"></div>
<script>
window.INITIAL_STATS = {{ stats | tojson }};
window.GENERATED_AT = "{{ generated_at }}";
</script>
</body>
</html>