@@ -7,7 +7,7 @@
|
||||
<meta name="description" content="{{ description }}">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body class="bg-white text-gray-900 antialiased">
|
||||
<body class="bg-white text-gray-900 antialiased flex flex-col min-h-screen">
|
||||
{% if user is defined and user %}
|
||||
{# ── Authenticated nav ─────────────────────────────────────── #}
|
||||
<nav class="border-b border-gray-200 pt-3">
|
||||
@@ -32,13 +32,31 @@
|
||||
{% else %}
|
||||
<a href="/orgs/{{ current_org }}/projects" class="font-medium text-gray-900 hover:text-black">{{ current_org }}</a>
|
||||
{% endif %}
|
||||
{% if project_name is defined and project_name %}
|
||||
{% if projects is defined and projects | length > 0 %}
|
||||
<span class="text-gray-300">/</span>
|
||||
<details class="relative">
|
||||
<summary class="font-medium text-gray-900 hover:text-black cursor-pointer list-none truncate">
|
||||
{% if project_name is defined and project_name %}{{ project_name }}{% else %}Select project{% endif %}
|
||||
<svg class="inline w-3 h-3 ml-0.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
|
||||
</summary>
|
||||
<div class="absolute left-0 mt-1 w-48 bg-white border border-gray-200 rounded-md shadow-lg z-20 py-1">
|
||||
{% for p in projects %}
|
||||
<a href="/orgs/{{ current_org }}/projects/{{ p }}" class="block px-3 py-1.5 text-sm hover:bg-gray-50{% if project_name is defined and p == project_name %} font-medium bg-gray-50{% endif %}">{{ p }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% elif project_name is defined and project_name %}
|
||||
<span class="text-gray-300">/</span>
|
||||
<a href="/orgs/{{ current_org }}/projects/{{ project_name }}" class="font-medium text-gray-900 hover:text-black truncate">{{ project_name }}</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center gap-4 shrink-0">
|
||||
<div class="flex items-center gap-3 shrink-0">
|
||||
<a href="/notifications" class="text-gray-400 hover:text-gray-900 relative" title="Notifications">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
|
||||
</svg>
|
||||
</a>
|
||||
<a href="/settings/account" class="text-sm text-gray-500 hover:text-gray-900">{{ user.username }}</a>
|
||||
<form method="POST" action="/logout" class="inline">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
@@ -49,18 +67,21 @@
|
||||
{# Tab navigation #}
|
||||
<div class="max-w-6xl mx-auto px-4 mt-2">
|
||||
<div class="flex gap-1 -mb-px overflow-x-auto">
|
||||
{% if current_org is defined and current_org %}
|
||||
{# Org-scoped tabs #}
|
||||
{% if project_name is defined and project_name %}
|
||||
{# ── Project-level tabs ─────────────────────────────── #}
|
||||
<a href="/orgs/{{ current_org }}/projects/{{ project_name }}" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'project_overview' %} text-gray-900 border-gray-900{% endif %}">Overview</a>
|
||||
<a href="/orgs/{{ current_org }}/projects/{{ project_name }}/releases" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'project_releases' %} text-gray-900 border-gray-900{% endif %}">Releases</a>
|
||||
{% elif current_org is defined and current_org %}
|
||||
{# ── Org-level tabs ─────────────────────────────────── #}
|
||||
<a href="/dashboard" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'dashboard' %} text-gray-900 border-gray-900{% endif %}">Overview</a>
|
||||
<a href="/orgs/{{ current_org }}/projects" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'projects' %} text-gray-900 border-gray-900{% endif %}">Projects</a>
|
||||
<a href="/orgs/{{ current_org }}/releases" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'releases' %} text-gray-900 border-gray-900{% endif %}">Releases</a>
|
||||
<a href="/orgs/{{ current_org }}/settings/members" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'members' %} text-gray-900 border-gray-900{% endif %}">Members</a>
|
||||
<a href="/orgs/{{ current_org }}/destinations" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'destinations' %} text-gray-900 border-gray-900{% endif %}">Destinations</a>
|
||||
<a href="/orgs/{{ current_org }}/usage" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'usage' %} text-gray-900 border-gray-900{% endif %}">Usage</a>
|
||||
<a href="/settings/tokens" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'tokens' %} text-gray-900 border-gray-900{% endif %}">Tokens</a>
|
||||
<a href="/settings/account" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'account' %} text-gray-900 border-gray-900{% endif %}">Settings</a>
|
||||
{% else %}
|
||||
{# Global tabs (dashboard, settings) #}
|
||||
{# ── Global tabs (no org context) ───────────────────── #}
|
||||
<a href="/dashboard" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'dashboard' %} text-gray-900 border-gray-900{% endif %}">Overview</a>
|
||||
<a href="/settings/tokens" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'tokens' %} text-gray-900 border-gray-900{% endif %}">Tokens</a>
|
||||
<a href="/settings/account" class="px-3 py-2 text-sm text-gray-500 hover:text-gray-900 border-b-2 border-transparent hover:border-gray-300{% if active_tab is defined and active_tab == 'account' %} text-gray-900 border-gray-900{% endif %}">Settings</a>
|
||||
@@ -82,11 +103,11 @@
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<main>
|
||||
<main class="flex-1">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="border-t border-gray-200 mt-24">
|
||||
<footer class="border-t border-gray-200 mt-auto pt-0">
|
||||
<div class="max-w-6xl mx-auto px-4 py-12">
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-8">
|
||||
<div>
|
||||
@@ -115,5 +136,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<script>
|
||||
document.querySelectorAll('time[datetime]').forEach(function(el) {
|
||||
try {
|
||||
var d = new Date(el.getAttribute('datetime'));
|
||||
if (!isNaN(d)) el.title = d.toLocaleString();
|
||||
} catch(e) {}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
235
templates/components/notifications_list.html.jinja
Normal file
235
templates/components/notifications_list.html.jinja
Normal file
@@ -0,0 +1,235 @@
|
||||
{% from "components/timestamp.html.jinja" import timeago as ts %}
|
||||
|
||||
{% if releases | length > 0 %}
|
||||
<div class="space-y-3">
|
||||
{% for r in releases %}
|
||||
<div class="border border-gray-200 rounded-lg overflow-hidden">
|
||||
{# ── Header row ─────────────────────────────────────────── #}
|
||||
<div class="px-4 py-3 flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
{# Status dot #}
|
||||
{% if r.summary_status == "RUNNING" %}
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-yellow-400 animate-pulse shrink-0"></span>
|
||||
{% elif r.summary_status == "QUEUED" %}
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-blue-400 shrink-0"></span>
|
||||
{% elif r.summary_status == "FAILED" %}
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-red-500 shrink-0"></span>
|
||||
{% elif r.summary_status == "SUCCEEDED" %}
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-green-500 shrink-0"></span>
|
||||
{% elif r.summary_status == "TIMED_OUT" %}
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-orange-400 shrink-0"></span>
|
||||
{% elif r.summary_status == "CANCELLED" %}
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-gray-400 shrink-0"></span>
|
||||
{% else %}
|
||||
<span class="w-2.5 h-2.5 rounded-full bg-gray-300 shrink-0"></span>
|
||||
{% endif %}
|
||||
|
||||
<a href="/orgs/{{ r.org }}/projects/{{ r.project }}/releases/{{ r.slug }}" class="font-medium text-gray-900 hover:text-black truncate">
|
||||
{% if r.commit_message %}{{ r.commit_message }}{% else %}{{ r.title }}{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-xs text-gray-500 shrink-0 flex-wrap">
|
||||
<a href="/orgs/{{ r.org }}/projects/{{ r.project }}" class="hover:underline">{{ r.org }}/{{ r.project }}</a>
|
||||
{% if r.branch %}
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z"/></svg>
|
||||
{{ r.branch }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if r.commit_sha %}
|
||||
<span class="font-mono">{{ r.commit_sha }}</span>
|
||||
{% endif %}
|
||||
<time>{{ ts(r.created_at) }}</time>
|
||||
{% if r.source_user %}
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
|
||||
<a href="/users/{{ r.source_user }}" class="hover:underline">{{ r.source_user }}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Summary + expandable details ────────────────────── #}
|
||||
<details class="border-t border-gray-100 group" data-slug="{{ r.slug }}" {% if r.summary_status == "RUNNING" or r.summary_status == "QUEUED" or r.summary_status == "FAILED" %}open{% endif %}>
|
||||
<summary class="px-4 py-2 flex items-center gap-2 text-sm cursor-pointer list-none hover:bg-gray-50 flex-wrap">
|
||||
{# Pipeline / env summary #}
|
||||
{% if r.has_pipeline and r.pipeline_stages | length > 0 %}
|
||||
{# Pipeline icon #}
|
||||
<svg class="w-3.5 h-3.5 text-purple-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
{# Stage badges #}
|
||||
{% for stage in r.pipeline_stages %}
|
||||
{% if stage.stage_type == "deploy" %}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100">
|
||||
{% if stage.status == "SUCCEEDED" %}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500"></span>
|
||||
{% elif stage.status == "RUNNING" %}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-yellow-500 animate-pulse"></span>
|
||||
{% elif stage.status == "QUEUED" %}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-blue-400"></span>
|
||||
{% elif stage.status == "FAILED" %}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-red-500"></span>
|
||||
{% else %}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-gray-300"></span>
|
||||
{% endif %}
|
||||
{{ stage.environment }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{# Done count #}
|
||||
{% set ns = namespace(done=0, total=0) %}
|
||||
{% for stage in r.pipeline_stages %}
|
||||
{% set ns.total = ns.total + 1 %}
|
||||
{% if stage.status == "SUCCEEDED" %}{% set ns.done = ns.done + 1 %}{% endif %}
|
||||
{% endfor %}
|
||||
<span class="text-xs text-gray-400">{{ ns.done }}/{{ ns.total }}</span>
|
||||
{% elif r.has_pipeline %}
|
||||
{# Pipeline exists but no stages yet #}
|
||||
<svg class="w-3.5 h-3.5 text-purple-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
{% set ns = namespace(all_done=true) %}
|
||||
{% for g in r.env_groups %}{% if g.status != "SUCCEEDED" %}{% set ns.all_done = false %}{% endif %}{% endfor %}
|
||||
{% if r.env_groups | length > 0 and ns.all_done %}
|
||||
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<span class="text-gray-500 text-sm">Deployed</span>
|
||||
{% else %}
|
||||
<svg class="w-4 h-4 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<span class="text-blue-600 text-sm">Queued</span>
|
||||
{% endif %}
|
||||
{% elif r.env_groups | length > 0 %}
|
||||
{# No pipeline, show env groups #}
|
||||
{% for g in r.env_groups %}
|
||||
{% if g.status == "RUNNING" %}
|
||||
<span class="w-4 h-4 shrink-0 flex items-center justify-center"><span class="w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse"></span></span>
|
||||
<span class="text-yellow-700 text-sm">Deploying to</span>
|
||||
{% elif g.status == "QUEUED" %}
|
||||
<svg class="w-4 h-4 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<span class="text-blue-600 text-sm">Queued for</span>
|
||||
{% elif g.status == "FAILED" %}
|
||||
<svg class="w-4 h-4 text-red-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<span class="text-red-600 text-sm">Failed on</span>
|
||||
{% elif g.status == "SUCCEEDED" %}
|
||||
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<span class="text-gray-500 text-sm">Deployed to</span>
|
||||
{% endif %}
|
||||
{% for env in g.envs %}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100">{{ env }}</span>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<svg class="w-4 h-4 text-gray-300 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<span class="text-gray-400 text-sm">Pending</span>
|
||||
{% endif %}
|
||||
|
||||
<svg class="w-3 h-3 text-gray-400 shrink-0 ml-auto transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
</summary>
|
||||
|
||||
{# ── Release details ─────────────────────────────────── #}
|
||||
<div class="px-4 py-3 border-t border-gray-100 space-y-3">
|
||||
{% if r.description %}
|
||||
<p class="text-sm text-gray-700">{{ r.description }}</p>
|
||||
{% endif %}
|
||||
<div class="flex flex-wrap gap-x-6 gap-y-2 text-xs text-gray-500">
|
||||
<span class="font-mono text-gray-400">{{ r.slug }}</span>
|
||||
{% if r.version %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">{{ r.version }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Pipeline stages ─────────────────────────────────── #}
|
||||
{% if r.has_pipeline and r.pipeline_stages | length > 0 %}
|
||||
<div class="border-t border-gray-100">
|
||||
{% for stage in r.pipeline_stages %}
|
||||
<div class="px-4 py-2.5 flex items-center gap-3 text-sm {{ 'border-b border-gray-50' if not loop.last else '' }} {{ 'opacity-50' if stage.status == 'PENDING' else '' }}">
|
||||
{# Status icon #}
|
||||
{% if stage.status == "SUCCEEDED" %}
|
||||
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{% elif stage.status == "RUNNING" %}
|
||||
<span class="w-4 h-4 shrink-0 flex items-center justify-center"><span class="w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse"></span></span>
|
||||
{% elif stage.status == "QUEUED" %}
|
||||
<svg class="w-4 h-4 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{% elif stage.status == "FAILED" %}
|
||||
<svg class="w-4 h-4 text-red-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{% else %}
|
||||
<svg class="w-4 h-4 text-gray-300 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" stroke-width="2"/></svg>
|
||||
{% endif %}
|
||||
|
||||
{# Stage label #}
|
||||
{% if stage.stage_type == "deploy" %}
|
||||
<span class="text-sm {{ 'text-gray-700' if stage.status == 'SUCCEEDED' else 'text-yellow-700' if stage.status == 'RUNNING' else 'text-red-700' if stage.status == 'FAILED' else 'text-gray-400' }}">
|
||||
{% if stage.status == "SUCCEEDED" %}Deployed to{% elif stage.status == "RUNNING" %}Deploying to{% elif stage.status == "QUEUED" %}Queued for{% elif stage.status == "FAILED" %}Failed on{% elif stage.status == "TIMED_OUT" %}Timed out on{% elif stage.status == "CANCELLED" %}Cancelled{% else %}Deploy to{% endif %}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100">
|
||||
{{ stage.environment }}
|
||||
</span>
|
||||
{% elif stage.stage_type == "wait" %}
|
||||
<span class="text-sm {{ 'text-gray-700' if stage.status == 'SUCCEEDED' else 'text-yellow-700' if stage.status == 'RUNNING' else 'text-gray-400' }}">
|
||||
{% if stage.status == "SUCCEEDED" %}Waited{% elif stage.status == "RUNNING" %}Waiting{% elif stage.status == "FAILED" %}Wait failed{% elif stage.status == "CANCELLED" %}Wait cancelled{% else %}Wait{% endif %}
|
||||
{% if stage.duration_seconds %}{{ stage.duration_seconds }}s{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{# Elapsed time #}
|
||||
{% if stage.started_at %}
|
||||
<span class="text-xs text-gray-400 tabular-nums">{{ ts(stage.started_at) }}</span>
|
||||
{% endif %}
|
||||
|
||||
<span class="ml-auto flex items-center gap-1 text-xs text-gray-400 shrink-0">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
pipeline
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Destinations ─────────────────────────────────────── #}
|
||||
{% if r.destinations | length > 0 %}
|
||||
{% for dest in r.destinations %}
|
||||
<div class="px-4 py-2 flex items-center gap-3 text-sm {{ 'border-b border-gray-50' if not loop.last else '' }} border-t border-gray-100">
|
||||
{# Status icon #}
|
||||
{% if dest.status == "SUCCEEDED" %}
|
||||
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{% elif dest.status == "RUNNING" or dest.status == "ASSIGNED" %}
|
||||
<span class="w-4 h-4 shrink-0 flex items-center justify-center"><span class="w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse"></span></span>
|
||||
{% elif dest.status == "QUEUED" %}
|
||||
<svg class="w-4 h-4 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{% elif dest.status == "FAILED" %}
|
||||
<svg class="w-4 h-4 text-red-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{% else %}
|
||||
<svg class="w-4 h-4 text-gray-300 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{% endif %}
|
||||
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100">
|
||||
{{ dest.environment }}
|
||||
</span>
|
||||
<span class="text-gray-400 text-xs">{{ dest.name }}</span>
|
||||
|
||||
{% if dest.status == "SUCCEEDED" %}
|
||||
<span class="text-xs text-green-600">Deployed</span>
|
||||
{% elif dest.status == "RUNNING" %}
|
||||
<span class="text-xs text-yellow-600">Deploying</span>
|
||||
{% elif dest.status == "QUEUED" %}
|
||||
<span class="text-xs text-blue-600">Queued{% if dest.queue_position %} #{{ dest.queue_position }}{% endif %}</span>
|
||||
{% elif dest.status == "FAILED" %}
|
||||
<span class="text-xs text-red-600">Failed</span>
|
||||
{% endif %}
|
||||
|
||||
{% if dest.completed_at %}
|
||||
<time class="text-xs text-gray-400 ml-auto">{{ ts(dest.completed_at) }}</time>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</details>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-16">
|
||||
<svg class="w-12 h-12 text-gray-300 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
|
||||
</svg>
|
||||
<p class="text-gray-500">No release activity yet.</p>
|
||||
<p class="text-sm text-gray-400 mt-1">Releases you create will appear here with their deployment status.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
9
templates/components/timestamp.html.jinja
Normal file
9
templates/components/timestamp.html.jinja
Normal file
@@ -0,0 +1,9 @@
|
||||
{# Reusable timestamp macro.
|
||||
Renders relative time with local-time tooltip on hover.
|
||||
Usage: {% from "components/timestamp.html.jinja" import timeago %}
|
||||
{{ timeago(item.created_at) }}
|
||||
{{ timeago(item.created_at, class="text-xs text-gray-400") }}
|
||||
#}
|
||||
{% macro timeago(value, class="") %}
|
||||
<time datetime="{{ value }}" class="{{ class }}">{{ value | timeago }}</time>
|
||||
{% endmacro %}
|
||||
@@ -1,124 +1,289 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
{% from "components/timestamp.html.jinja" import timeago as ts %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-4xl mx-auto px-4 pt-12">
|
||||
<section class="max-w-4xl mx-auto px-4 pt-12 pb-12">
|
||||
{# ── Breadcrumb + heading ──────────────────────────────────── #}
|
||||
<div class="mb-8">
|
||||
<a href="/orgs/{{ org_name }}/projects/{{ project_name }}" class="text-sm text-gray-500 hover:text-gray-700">← {{ project_name }}</a>
|
||||
<div class="flex items-center gap-3 mt-1">
|
||||
<h1 class="text-2xl font-bold">{{ artifact.title }}</h1>
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 mb-2">
|
||||
<a href="/orgs/{{ org_name }}/projects/{{ project_name }}" class="hover:text-gray-700">{{ project_name }}</a>
|
||||
<span class="text-gray-300">/</span>
|
||||
<a href="/orgs/{{ org_name }}/projects/{{ project_name }}/releases" class="hover:text-gray-700">Releases</a>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
{# Status dot #}
|
||||
{% if summary_status == "RUNNING" %}
|
||||
<span class="w-3 h-3 rounded-full bg-yellow-400 animate-pulse shrink-0"></span>
|
||||
{% elif summary_status == "QUEUED" %}
|
||||
<span class="w-3 h-3 rounded-full bg-blue-400 shrink-0"></span>
|
||||
{% elif summary_status == "FAILED" %}
|
||||
<span class="w-3 h-3 rounded-full bg-red-500 shrink-0"></span>
|
||||
{% elif summary_status == "SUCCEEDED" %}
|
||||
<span class="w-3 h-3 rounded-full bg-green-500 shrink-0"></span>
|
||||
{% elif summary_status == "TIMED_OUT" %}
|
||||
<span class="w-3 h-3 rounded-full bg-orange-400 shrink-0"></span>
|
||||
{% elif summary_status == "CANCELLED" %}
|
||||
<span class="w-3 h-3 rounded-full bg-gray-400 shrink-0"></span>
|
||||
{% else %}
|
||||
<span class="w-3 h-3 rounded-full bg-gray-300 shrink-0"></span>
|
||||
{% endif %}
|
||||
|
||||
<h1 class="text-2xl font-bold">
|
||||
{% if artifact.commit_message %}{{ artifact.commit_message }}{% else %}{{ artifact.title }}{% endif %}
|
||||
</h1>
|
||||
{% if artifact.version %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded text-sm font-medium bg-green-100 text-green-800">{{ artifact.version }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-1 font-mono">{{ artifact.slug }}</p>
|
||||
<p class="text-sm font-mono text-gray-400 mt-1">{{ artifact.slug }}</p>
|
||||
|
||||
{# ── Metadata pills ────────────────────────────────────── #}
|
||||
<div class="flex flex-wrap items-center gap-2 mt-3">
|
||||
{% if artifact.branch %}
|
||||
<span class="inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full bg-blue-50 text-blue-700 border border-blue-200">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
|
||||
{{ artifact.branch }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if artifact.commit_sha %}
|
||||
<span class="inline-flex items-center gap-1.5 text-xs font-mono px-2.5 py-1 rounded-full bg-gray-100 text-gray-600 border border-gray-200">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/></svg>
|
||||
{{ artifact.commit_sha[:8] }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if artifact.source_type %}
|
||||
<span class="inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full bg-purple-50 text-purple-700 border border-purple-200">
|
||||
{% if 'github' in artifact.source_type %}
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
{% elif 'gitlab' in artifact.source_type %}
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 01-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 014.82 2a.43.43 0 01.58 0 .42.42 0 01.11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0118.6 2a.43.43 0 01.58 0 .42.42 0 01.11.18l2.44 7.51L23 13.45a.84.84 0 01-.35.94z"/></svg>
|
||||
{% else %}
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
{% endif %}
|
||||
{{ artifact.source_type | replace("_", " ") | replace("-", " ") }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if artifact.source_user %}
|
||||
<a href="/users/{{ artifact.source_user }}" class="inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full bg-amber-50 text-amber-700 border border-amber-200 hover:bg-amber-100 transition-colors">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
|
||||
{{ artifact.source_user }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<span class="text-xs text-gray-400">{{ ts(artifact.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if artifact.description %}
|
||||
<div class="mb-6">
|
||||
{% if artifact.description and not artifact.description is startingwith("Branch:") %}
|
||||
<div class="mb-8">
|
||||
<p class="text-gray-700">{{ artifact.description }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Git info -->
|
||||
{% if artifact.commit_sha or artifact.branch %}
|
||||
<div class="p-4 border border-gray-200 rounded-lg">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">Git</h3>
|
||||
<dl class="space-y-2 text-sm">
|
||||
{% if artifact.branch %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Branch</dt>
|
||||
<dd class="font-mono text-blue-700">{{ artifact.branch }}</dd>
|
||||
</div>
|
||||
{# ── Pipeline stages ───────────────────────────────────────── #}
|
||||
{% if has_pipeline and pipeline_stages | length > 0 %}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-sm font-semibold text-gray-900 mb-3">Pipeline</h2>
|
||||
<div class="border border-gray-200 rounded-lg divide-y divide-gray-100">
|
||||
{% for stage in pipeline_stages %}
|
||||
<div class="px-4 py-3 flex items-center gap-3 text-sm {{ 'opacity-50' if stage.status == 'PENDING' else '' }}">
|
||||
{# Status icon #}
|
||||
{% if stage.status == "SUCCEEDED" %}
|
||||
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{% elif stage.status == "RUNNING" %}
|
||||
<span class="w-4 h-4 shrink-0 flex items-center justify-center"><span class="w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse"></span></span>
|
||||
{% elif stage.status == "QUEUED" %}
|
||||
<svg class="w-4 h-4 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{% elif stage.status == "FAILED" %}
|
||||
<svg class="w-4 h-4 text-red-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{% else %}
|
||||
<svg class="w-4 h-4 text-gray-300 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" stroke-width="2"/></svg>
|
||||
{% endif %}
|
||||
{% if artifact.commit_sha %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Commit</dt>
|
||||
<dd class="font-mono">{{ artifact.commit_sha[:8] }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if artifact.commit_message %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Message</dt>
|
||||
<dd class="text-gray-700 truncate ml-4">{{ artifact.commit_message }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if artifact.repo_url %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Repository</dt>
|
||||
<dd><a href="{{ artifact.repo_url }}" class="text-blue-600 hover:underline" target="_blank" rel="noopener">View repo</a></dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Source info -->
|
||||
{% if artifact.source_user or artifact.source_type %}
|
||||
<div class="p-4 border border-gray-200 rounded-lg">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">Source</h3>
|
||||
<dl class="space-y-2 text-sm">
|
||||
{% if artifact.source_user %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Created by</dt>
|
||||
<dd class="text-gray-700">{{ artifact.source_user }}</dd>
|
||||
</div>
|
||||
{# Stage label #}
|
||||
{% if stage.stage_type == "deploy" %}
|
||||
<span class="text-sm {{ 'text-gray-700' if stage.status == 'SUCCEEDED' else 'text-yellow-700' if stage.status == 'RUNNING' else 'text-red-700' if stage.status == 'FAILED' else 'text-gray-400' }}">
|
||||
{% if stage.status == "SUCCEEDED" %}Deployed to{% elif stage.status == "RUNNING" %}Deploying to{% elif stage.status == "QUEUED" %}Queued for{% elif stage.status == "FAILED" %}Failed on{% elif stage.status == "TIMED_OUT" %}Timed out on{% elif stage.status == "CANCELLED" %}Cancelled{% else %}Deploy to{% endif %}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {% if 'prod' in stage.environment and 'preprod' not in stage.environment %}bg-pink-100 text-pink-800{% elif 'preprod' in stage.environment or 'pre-prod' in stage.environment %}bg-orange-100 text-orange-800{% elif 'stag' in stage.environment %}bg-yellow-100 text-yellow-800{% elif 'dev' in stage.environment %}bg-violet-100 text-violet-800{% else %}bg-gray-100 text-gray-700{% endif %}">
|
||||
{{ stage.environment }}
|
||||
</span>
|
||||
{% elif stage.stage_type == "wait" %}
|
||||
<span class="text-sm {{ 'text-gray-700' if stage.status == 'SUCCEEDED' else 'text-yellow-700' if stage.status == 'RUNNING' else 'text-gray-400' }}">
|
||||
{% if stage.status == "SUCCEEDED" %}Waited{% elif stage.status == "RUNNING" %}Waiting{% elif stage.status == "FAILED" %}Wait failed{% elif stage.status == "CANCELLED" %}Wait cancelled{% else %}Wait{% endif %}
|
||||
{% if stage.duration_seconds %}{{ stage.duration_seconds }}s{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if artifact.source_email %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Email</dt>
|
||||
<dd class="text-gray-700">{{ artifact.source_email }}</dd>
|
||||
</div>
|
||||
|
||||
{# Elapsed time #}
|
||||
{% if stage.started_at %}
|
||||
<span class="text-xs text-gray-400 tabular-nums">{{ ts(stage.started_at) }}</span>
|
||||
{% endif %}
|
||||
{% if artifact.source_type %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">Type</dt>
|
||||
<dd class="text-gray-700">{{ artifact.source_type }}</dd>
|
||||
</div>
|
||||
|
||||
{# Error message #}
|
||||
{% if stage.error_message %}
|
||||
<span class="text-xs text-red-600 ml-auto">{{ stage.error_message }}</span>
|
||||
{% endif %}
|
||||
{% if artifact.run_url %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500">CI Run</dt>
|
||||
<dd><a href="{{ artifact.run_url }}" class="text-blue-600 hover:underline" target="_blank" rel="noopener">View run</a></dd>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{# Pipeline progress #}
|
||||
{% set ns = namespace(done=0, total=0) %}
|
||||
{% for stage in pipeline_stages %}
|
||||
{% set ns.total = ns.total + 1 %}
|
||||
{% if stage.status == "SUCCEEDED" %}{% set ns.done = ns.done + 1 %}{% endif %}
|
||||
{% endfor %}
|
||||
<p class="text-xs text-gray-400 mt-2">{{ ns.done }}/{{ ns.total }} stages complete</p>
|
||||
</div>
|
||||
{% elif has_pipeline %}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-sm font-semibold text-gray-900 mb-3">Pipeline</h2>
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 p-4 border border-gray-200 rounded-lg">
|
||||
<svg class="w-4 h-4 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<span>Pipeline configured — waiting for release to be triggered.</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Destinations with status ──────────────────────────────── #}
|
||||
{% if destinations | length > 0 or configured_destinations | length > 0 %}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-sm font-semibold text-gray-900 mb-3">Destinations</h2>
|
||||
<div class="border border-gray-200 rounded-lg divide-y divide-gray-100">
|
||||
{% for dest in destinations %}
|
||||
<div class="px-4 py-3 flex items-center gap-3 text-sm">
|
||||
{# Status icon #}
|
||||
{% if dest.status == "SUCCEEDED" %}
|
||||
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{% elif dest.status == "RUNNING" or dest.status == "ASSIGNED" %}
|
||||
<span class="w-4 h-4 shrink-0 flex items-center justify-center"><span class="w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse"></span></span>
|
||||
{% elif dest.status == "QUEUED" %}
|
||||
<svg class="w-4 h-4 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{% elif dest.status == "FAILED" %}
|
||||
<svg class="w-4 h-4 text-red-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{% else %}
|
||||
<svg class="w-4 h-4 text-gray-300 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
||||
{# Environment badge #}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {% if 'prod' in dest.environment and 'preprod' not in dest.environment %}bg-pink-100 text-pink-800{% elif 'preprod' in dest.environment or 'pre-prod' in dest.environment %}bg-orange-100 text-orange-800{% elif 'stag' in dest.environment %}bg-yellow-100 text-yellow-800{% elif 'dev' in dest.environment %}bg-violet-100 text-violet-800{% else %}bg-gray-100 text-gray-700{% endif %}">
|
||||
{{ dest.environment }}
|
||||
</span>
|
||||
|
||||
<span class="text-gray-700">{{ dest.name }}</span>
|
||||
|
||||
{# Status label #}
|
||||
{% if dest.status == "SUCCEEDED" %}
|
||||
<span class="text-xs text-green-600">Deployed</span>
|
||||
{% elif dest.status == "RUNNING" %}
|
||||
<span class="text-xs text-yellow-600">Deploying</span>
|
||||
{% elif dest.status == "QUEUED" %}
|
||||
<span class="text-xs text-blue-600">Queued{% if dest.queue_position %} #{{ dest.queue_position }}{% endif %}</span>
|
||||
{% elif dest.status == "FAILED" %}
|
||||
<span class="text-xs text-red-600">Failed</span>
|
||||
{% elif dest.status == "TIMED_OUT" %}
|
||||
<span class="text-xs text-orange-600">Timed out</span>
|
||||
{% elif dest.status == "CANCELLED" %}
|
||||
<span class="text-xs text-gray-500">Cancelled</span>
|
||||
{% endif %}
|
||||
|
||||
{# Error message #}
|
||||
{% if dest.error_message %}
|
||||
<span class="text-xs text-red-600 truncate ml-auto max-w-xs" title="{{ dest.error_message }}">{{ dest.error_message }}</span>
|
||||
{% endif %}
|
||||
|
||||
{# Timestamp #}
|
||||
{% if dest.completed_at %}
|
||||
<time class="text-xs text-gray-400 ml-auto tabular-nums">{{ ts(dest.completed_at) }}</time>
|
||||
{% elif dest.started_at %}
|
||||
<time class="text-xs text-gray-400 ml-auto tabular-nums">{{ ts(dest.started_at) }}</time>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Show configured destinations that don't have live state yet #}
|
||||
{% if configured_destinations | length > 0 and destinations | length == 0 %}
|
||||
<div class="border border-gray-200 rounded-lg divide-y divide-gray-100">
|
||||
{% for cd in configured_destinations %}
|
||||
<div class="px-4 py-3 flex items-center gap-3 text-sm">
|
||||
<svg class="w-4 h-4 text-gray-300 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" stroke-width="2"/></svg>
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {% if 'prod' in cd.environment and 'preprod' not in cd.environment %}bg-pink-100 text-pink-800{% elif 'preprod' in cd.environment or 'pre-prod' in cd.environment %}bg-orange-100 text-orange-800{% elif 'stag' in cd.environment %}bg-yellow-100 text-yellow-800{% elif 'dev' in cd.environment %}bg-violet-100 text-violet-800{% else %}bg-gray-100 text-gray-700{% endif %}">
|
||||
{{ cd.environment }}
|
||||
</span>
|
||||
<span class="text-gray-700">{{ cd.name }}</span>
|
||||
<span class="text-xs text-gray-400">Not deployed</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Links -->
|
||||
{% if artifact.web or artifact.pr %}
|
||||
{# ── Logs ──────────────────────────────────────────────────── #}
|
||||
{% if has_release_intents %}
|
||||
<div class="mb-8">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">Links</h3>
|
||||
<div class="flex gap-3">
|
||||
{% if artifact.web %}
|
||||
<a href="{{ artifact.web }}" class="inline-flex items-center px-3 py-1.5 border border-gray-200 rounded-md text-sm text-gray-700 hover:bg-gray-50" target="_blank" rel="noopener">Web</a>
|
||||
<h2 class="text-sm font-semibold text-gray-900 mb-3">Logs</h2>
|
||||
<release-logs url="/api/orgs/{{ org_name }}/projects/{{ project_name }}/releases/{{ artifact.slug }}/logs"></release-logs>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Details ───────────────────────────────────────────────── #}
|
||||
{% if artifact.commit_message or artifact.repo_url or artifact.source_email or artifact.run_url %}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-sm font-semibold text-gray-900 mb-3">Details</h2>
|
||||
<div class="border border-gray-200 rounded-lg divide-y divide-gray-100">
|
||||
{% if artifact.commit_message %}
|
||||
<div class="px-4 py-3 flex items-center gap-3 text-sm">
|
||||
<svg class="w-4 h-4 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/></svg>
|
||||
<span class="text-gray-700">{{ artifact.commit_message }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if artifact.pr %}
|
||||
<a href="{{ artifact.pr }}" class="inline-flex items-center px-3 py-1.5 border border-gray-200 rounded-md text-sm text-gray-700 hover:bg-gray-50" target="_blank" rel="noopener">Pull request</a>
|
||||
{% if artifact.repo_url %}
|
||||
<div class="px-4 py-3 flex items-center gap-3 text-sm">
|
||||
<svg class="w-4 h-4 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/></svg>
|
||||
<a href="{{ artifact.repo_url }}" class="text-blue-600 hover:underline" target="_blank" rel="noopener">{{ artifact.repo_url }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if artifact.source_email %}
|
||||
<div class="px-4 py-3 flex items-center gap-3 text-sm">
|
||||
<svg class="w-4 h-4 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
|
||||
<span class="text-gray-500">{{ artifact.source_email }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if artifact.run_url %}
|
||||
<div class="px-4 py-3 flex items-center gap-3 text-sm">
|
||||
<svg class="w-4 h-4 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<a href="{{ artifact.run_url }}" class="text-blue-600 hover:underline" target="_blank" rel="noopener">View CI run</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Destinations -->
|
||||
{% if artifact.destinations %}
|
||||
{# ── Links ─────────────────────────────────────────────────── #}
|
||||
{% if artifact.web or artifact.pr %}
|
||||
<div class="mb-8">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3">Destinations</h3>
|
||||
<div class="space-y-2">
|
||||
{% for dest in artifact.destinations %}
|
||||
<div class="flex items-center gap-3 p-3 border border-gray-200 rounded-lg">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700">{{ dest.environment }}</span>
|
||||
<span class="text-sm text-gray-900">{{ dest.name }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<h2 class="text-sm font-semibold text-gray-900 mb-3">Links</h2>
|
||||
<div class="flex gap-3">
|
||||
{% if artifact.web %}
|
||||
<a href="{{ artifact.web }}" class="inline-flex items-center px-3 py-1.5 border border-gray-200 rounded-md text-sm text-gray-700 hover:bg-gray-50" target="_blank" rel="noopener">
|
||||
<svg class="w-4 h-4 mr-1.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||
Web
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if artifact.pr %}
|
||||
<a href="{{ artifact.pr }}" class="inline-flex items-center px-3 py-1.5 border border-gray-200 rounded-md text-sm text-gray-700 hover:bg-gray-50" target="_blank" rel="noopener">
|
||||
<svg class="w-4 h-4 mr-1.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
|
||||
Pull request
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="text-sm text-gray-500">
|
||||
<p>Created {{ artifact.created_at }}</p>
|
||||
<p>Created {{ ts(artifact.created_at) }}</p>
|
||||
</div>
|
||||
</section>
|
||||
<script src="/static/js/components/forage-components.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
{% from "components/timestamp.html.jinja" import timeago as ts %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-2xl mx-auto px-4 py-12">
|
||||
@@ -30,16 +31,25 @@
|
||||
{% if recent_activity %}
|
||||
<div class="space-y-2">
|
||||
{% for item in recent_activity %}
|
||||
<a href="/orgs/{{ item.org_name }}/projects/{{ item.project_name }}" class="block px-4 py-3 border border-gray-200 rounded-lg hover:border-gray-300 hover:bg-gray-50 transition-colors">
|
||||
<a href="/orgs/{{ item.org_name }}/projects/{{ item.project_name }}/releases/{{ item.slug }}" class="block px-4 py-3 border border-gray-200 rounded-lg hover:border-gray-300 hover:bg-gray-50 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium truncate">{{ item.title }}</p>
|
||||
<p class="text-sm text-gray-500 mt-0.5">
|
||||
{{ item.org_name }} / {{ item.project_name }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right text-sm text-gray-400 shrink-0 ml-4">
|
||||
<p class="font-mono text-xs">{{ item.slug }}</p>
|
||||
<div class="text-right shrink-0 ml-4">
|
||||
{% if item.dest_envs %}
|
||||
<div class="flex gap-1 mb-1 justify-end">
|
||||
{% for env in item.dest_envs %}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {% if 'prod' in env and 'preprod' not in env %}bg-pink-100 text-pink-800{% elif 'preprod' in env or 'pre-prod' in env %}bg-orange-100 text-orange-800{% elif 'stag' in env %}bg-yellow-100 text-yellow-800{% elif 'dev' in env %}bg-violet-100 text-violet-800{% else %}bg-gray-100 text-gray-700{% endif %}">
|
||||
{{ env }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ ts(item.created_at, class="text-xs text-gray-400") }}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
142
templates/pages/destination_detail.html.jinja
Normal file
142
templates/pages/destination_detail.html.jinja
Normal file
@@ -0,0 +1,142 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-3xl mx-auto px-4 py-12">
|
||||
<nav class="flex items-center gap-1.5 text-sm text-gray-500 mb-6">
|
||||
<a href="/orgs/{{ org_name }}/destinations" class="hover:text-gray-900 transition-colors">Destinations</a>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
<span class="text-gray-900 font-medium">{{ dest_name }}</span>
|
||||
</nav>
|
||||
|
||||
<div class="flex items-start justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ dest_name }}</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
Environment:
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full shrink-0 {% if 'prod' in dest_environment and 'preprod' not in dest_environment %}bg-pink-500{% elif 'preprod' in dest_environment or 'pre-prod' in dest_environment %}bg-orange-500{% elif 'stag' in dest_environment %}bg-yellow-500{% elif 'dev' in dest_environment %}bg-violet-500{% else %}bg-gray-400{% endif %}"></span>
|
||||
<span class="font-medium text-gray-700">{{ dest_environment }}</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Type info #}
|
||||
<div class="border border-gray-200 rounded-lg overflow-hidden mb-6">
|
||||
<div class="px-5 py-3 bg-gray-50">
|
||||
<span class="text-sm font-medium text-gray-900">Type</span>
|
||||
</div>
|
||||
<div class="px-5 py-4">
|
||||
{% if dest_type_name %}
|
||||
<div class="flex items-center gap-4">
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">Name</span>
|
||||
<p class="text-sm font-medium text-gray-900">{{ dest_type_name }}</p>
|
||||
</div>
|
||||
{% if dest_type_organisation %}
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">Organisation</span>
|
||||
<p class="text-sm font-medium text-gray-900">{{ dest_type_organisation }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if dest_type_version %}
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">Version</span>
|
||||
<p class="text-sm font-medium text-gray-900">v{{ dest_type_version }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-400">No type assigned</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Metadata section #}
|
||||
<div class="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div class="px-5 py-3 bg-gray-50 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-900">Metadata</span>
|
||||
{% if metadata | length > 0 %}
|
||||
<span class="text-xs text-gray-400">{{ metadata | length }} key{% if metadata | length != 1 %}s{% endif %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if is_admin %}
|
||||
<form method="post" action="/orgs/{{ org_name }}/destinations/detail/update?name={{ dest_name | urlencode }}" id="metadata-form">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<div class="divide-y divide-gray-100" id="metadata-rows">
|
||||
{% for entry in metadata %}
|
||||
<div class="px-5 py-3 flex items-center gap-3 metadata-row">
|
||||
<input type="text" name="metadata_keys" value="{{ entry.key }}"
|
||||
placeholder="key"
|
||||
class="flex-1 text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent font-mono">
|
||||
<input type="text" name="metadata_values" value="{{ entry.value }}"
|
||||
placeholder="value"
|
||||
class="flex-1 text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent font-mono">
|
||||
<button type="button" onclick="this.closest('.metadata-row').remove()"
|
||||
class="text-gray-300 hover:text-red-500 transition-colors p-1" title="Remove row">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{# One empty row to start #}
|
||||
<div class="px-5 py-3 flex items-center gap-3 metadata-row">
|
||||
<input type="text" name="metadata_keys" placeholder="key"
|
||||
class="flex-1 text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent font-mono">
|
||||
<input type="text" name="metadata_values" placeholder="value"
|
||||
class="flex-1 text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent font-mono">
|
||||
<button type="button" onclick="this.closest('.metadata-row').remove()"
|
||||
class="text-gray-300 hover:text-red-500 transition-colors p-1" title="Remove row">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-5 py-3 border-t border-gray-100 flex items-center justify-between">
|
||||
<button type="button" id="add-row-btn"
|
||||
class="text-sm text-green-600 hover:text-green-700 font-medium transition-colors flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||
Add row
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-md hover:bg-green-700 transition-colors">
|
||||
Save metadata
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
{# Read-only view for non-admins #}
|
||||
{% if metadata | length > 0 %}
|
||||
<div class="divide-y divide-gray-100">
|
||||
{% for entry in metadata %}
|
||||
<div class="px-5 py-3 flex items-center gap-3">
|
||||
<span class="flex-1 text-sm font-mono text-gray-600">{{ entry.key }}</span>
|
||||
<span class="flex-1 text-sm font-mono text-gray-900">{{ entry.value }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-5 py-4 text-sm text-gray-400">No metadata</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
document.getElementById('add-row-btn')?.addEventListener('click', function() {
|
||||
const container = document.getElementById('metadata-rows');
|
||||
const row = document.createElement('div');
|
||||
row.className = 'px-5 py-3 flex items-center gap-3 metadata-row';
|
||||
row.innerHTML = `
|
||||
<input type="text" name="metadata_keys" placeholder="key"
|
||||
class="flex-1 text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent font-mono">
|
||||
<input type="text" name="metadata_values" placeholder="value"
|
||||
class="flex-1 text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent font-mono">
|
||||
<button type="button" onclick="this.closest('.metadata-row').remove()"
|
||||
class="text-gray-300 hover:text-red-500 transition-colors p-1" title="Remove row">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||
</button>`;
|
||||
container.appendChild(row);
|
||||
row.querySelector('input').focus();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -3,63 +3,181 @@
|
||||
{% block content %}
|
||||
<section class="max-w-4xl mx-auto px-4 py-12">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-2xl font-bold">Destinations</h1>
|
||||
<h1 class="text-2xl font-bold">Environments & Destinations</h1>
|
||||
</div>
|
||||
|
||||
{# ── Create destination form (admin only) ─────────────────── #}
|
||||
{% if is_admin %}
|
||||
<div class="border border-gray-200 rounded-lg p-5 mb-6">
|
||||
<h2 class="text-sm font-medium text-gray-900 mb-3">Add destination</h2>
|
||||
<form method="POST" action="/orgs/{{ org_name }}/destinations" class="flex items-end gap-3 flex-wrap">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<div class="flex-1 min-w-[140px]">
|
||||
<label for="dest-name" class="block text-xs text-gray-500 mb-1">Name</label>
|
||||
<input type="text" id="dest-name" name="name" required placeholder="my-service" class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent">
|
||||
</div>
|
||||
<div class="min-w-[140px]">
|
||||
<label for="dest-env" class="block text-xs text-gray-500 mb-1">Environment</label>
|
||||
<select id="dest-env" name="environment" class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent">
|
||||
<option value="staging">staging</option>
|
||||
<option value="preprod">preprod</option>
|
||||
<option value="production">production</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-1.5 bg-gray-900 text-white text-sm rounded-md hover:bg-gray-800">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Destination list ─────────────────────────────────────── #}
|
||||
{% if destinations | length > 0 %}
|
||||
<div class="space-y-2">
|
||||
{% for dest in destinations %}
|
||||
<div class="border border-gray-200 rounded-lg px-5 py-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="w-2 h-2 rounded-full shrink-0 {% if dest.environment == 'production' %}bg-purple-500{% elif dest.environment == 'preprod' %}bg-orange-500{% elif dest.environment == 'staging' %}bg-yellow-500{% else %}bg-gray-400{% endif %}"></span>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-gray-900">{{ dest.name }}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-full {% if dest.environment == 'production' %}bg-purple-100 text-purple-800{% elif dest.environment == 'preprod' %}bg-orange-100 text-orange-800{% elif dest.environment == 'staging' %}bg-yellow-100 text-yellow-800{% else %}bg-gray-100 text-gray-600{% endif %}">{{ dest.environment }}</span>
|
||||
</div>
|
||||
{% if dest.artifact_title %}
|
||||
<p class="text-sm text-gray-500 mt-0.5">
|
||||
Last: <a href="/orgs/{{ org_name }}/projects/{{ dest.project_name }}/releases/{{ dest.artifact_slug }}" class="text-gray-700 hover:text-black underline">{{ dest.artifact_title }}</a>
|
||||
in <a href="/orgs/{{ org_name }}/projects/{{ dest.project_name }}" class="text-gray-700 hover:text-black underline">{{ dest.project_name }}</a>
|
||||
</p>
|
||||
{% if environments | length > 0 %}
|
||||
<div class="space-y-6">
|
||||
{% for env in environments %}
|
||||
<div class="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div class="px-5 py-3 bg-gray-50 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full shrink-0 {% if 'prod' in env.name and 'preprod' not in env.name %}bg-pink-500{% elif 'preprod' in env.name or 'pre-prod' in env.name %}bg-orange-500{% elif 'stag' in env.name %}bg-yellow-500{% elif 'dev' in env.name %}bg-violet-500{% else %}bg-gray-400{% endif %}"></span>
|
||||
<span class="font-medium text-gray-900">{{ env.name }}</span>
|
||||
{% if env.description %}
|
||||
<span class="text-xs text-gray-500">— {{ env.description }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">order: {{ env.sort_order }}</span>
|
||||
</div>
|
||||
{% if dest.created_at %}
|
||||
<span class="text-xs text-gray-400 shrink-0" title="{{ dest.created_at | datetime }}">{{ dest.created_at | timeago }}</span>
|
||||
|
||||
{% if env.destinations | length > 0 %}
|
||||
<div class="divide-y divide-gray-100">
|
||||
{% for dest in env.destinations %}
|
||||
<a href="/orgs/{{ org_name }}/destinations/detail?name={{ dest.name | urlencode }}" class="px-5 py-3 flex items-center justify-between hover:bg-gray-50 transition-colors block">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<svg class="w-4 h-4 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
<span class="text-sm font-medium text-gray-900">{{ dest.name }}</span>
|
||||
{% if dest.type_name %}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-full bg-blue-50 text-blue-700">{{ dest.type_name }}{% if dest.type_version %} v{{ dest.type_version }}{% endif %}</span>
|
||||
{% endif %}
|
||||
{% if dest.metadata | length > 0 %}
|
||||
<span class="text-xs text-gray-400">{{ dest.metadata | length }} key{% if dest.metadata | length != 1 %}s{% endif %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-5 py-3 text-sm text-gray-400">No destinations in this environment</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_admin %}
|
||||
<details class="border-t border-gray-100">
|
||||
<summary class="px-5 py-3 bg-gray-50/50 text-sm text-gray-500 cursor-pointer hover:text-gray-700 select-none">Add destination to {{ env.name }}</summary>
|
||||
<div class="px-5 py-4">
|
||||
<form method="post" action="/orgs/{{ org_name }}/destinations/create" class="space-y-3">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="environment" value="{{ env.name }}">
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Name</label>
|
||||
<input type="text" name="name" placeholder="e.g. my-app-prod" required
|
||||
class="w-full text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent">
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Type name <span class="text-gray-400">(optional)</span></label>
|
||||
<input type="text" name="type_name" placeholder="e.g. kubernetes"
|
||||
class="w-full text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Type org <span class="text-gray-400">(optional)</span></label>
|
||||
<input type="text" name="type_organisation" placeholder="defaults to {{ org_name }}"
|
||||
class="w-full text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Type version</label>
|
||||
<input type="number" name="type_version" value="1" min="1"
|
||||
class="w-full text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Metadata <span class="text-gray-400">(optional key-value pairs)</span></label>
|
||||
<div class="space-y-1.5 create-meta-rows">
|
||||
<div class="flex gap-2 items-center metadata-row">
|
||||
<input type="text" name="metadata_keys" placeholder="key" class="flex-1 text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-1 focus:ring-green-500 font-mono">
|
||||
<input type="text" name="metadata_values" placeholder="value" class="flex-1 text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-1 focus:ring-green-500 font-mono">
|
||||
<button type="button" onclick="this.closest('.metadata-row').remove()"
|
||||
class="text-gray-300 hover:text-red-500 transition-colors p-1" title="Remove row">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="addMetaRow(this.previousElementSibling)"
|
||||
class="mt-1.5 text-xs text-green-600 hover:text-green-700 font-medium transition-colors flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||
Add row
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-md hover:bg-green-700 transition-colors">Create destination</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if orphan_destinations | length > 0 %}
|
||||
<div class="mt-6 border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div class="px-5 py-3 bg-gray-50">
|
||||
<span class="font-medium text-gray-700">Other destinations</span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
{% for dest in orphan_destinations %}
|
||||
<a href="/orgs/{{ org_name }}/destinations/detail?name={{ dest.name | urlencode }}" class="px-5 py-3 flex items-center justify-between hover:bg-gray-50 transition-colors block">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-4 h-4 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
<span class="text-sm text-gray-900">{{ dest.name }}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-full bg-gray-100 text-gray-600">{{ dest.environment }}</span>
|
||||
{% if dest.type_name %}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded-full bg-blue-50 text-blue-700">{{ dest.type_name }}{% if dest.type_version %} v{{ dest.type_version }}{% endif %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="border border-gray-200 rounded-lg p-8 text-center text-gray-500">
|
||||
<p class="font-medium text-gray-700">No destinations yet</p>
|
||||
<p class="mt-1 text-sm">Destinations appear when releases are deployed with <code class="bg-gray-100 px-1.5 py-0.5 rounded text-gray-700">forest release create --dest</code></p>
|
||||
<p class="font-medium text-gray-700">No environments yet</p>
|
||||
<p class="mt-1 text-sm">Create your first environment below, or use <code class="bg-gray-100 px-1.5 py-0.5 rounded text-gray-700">forest env create</code> from the CLI.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_admin %}
|
||||
<div class="mt-8 border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div class="px-5 py-3 bg-gray-50">
|
||||
<span class="font-medium text-gray-900">Create environment</span>
|
||||
</div>
|
||||
<div class="px-5 py-4">
|
||||
<form method="post" action="/orgs/{{ org_name }}/destinations/environments" class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Name</label>
|
||||
<input type="text" name="name" placeholder="e.g. production" required
|
||||
class="w-full text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent">
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Description <span class="text-gray-400">(optional)</span></label>
|
||||
<input type="text" name="description" placeholder="e.g. Live production environment"
|
||||
class="w-full text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent">
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">Order</label>
|
||||
<input type="number" name="sort_order" value="0"
|
||||
class="w-full text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent">
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-md hover:bg-green-700 transition-colors">Create</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<script>
|
||||
function addMetaRow(container) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'flex gap-2 items-center metadata-row';
|
||||
row.innerHTML = `
|
||||
<input type="text" name="metadata_keys" placeholder="key" class="flex-1 text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-1 focus:ring-green-500 font-mono">
|
||||
<input type="text" name="metadata_values" placeholder="value" class="flex-1 text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-1 focus:ring-green-500 font-mono">
|
||||
<button type="button" onclick="this.closest('.metadata-row').remove()"
|
||||
class="text-gray-300 hover:text-red-500 transition-colors p-1" title="Remove row">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||
</button>`;
|
||||
container.appendChild(row);
|
||||
row.querySelector('input').focus();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
<p class="text-6xl font-bold text-gray-300">{{ status }}</p>
|
||||
<h1 class="mt-4 text-2xl font-bold">{{ heading }}</h1>
|
||||
<p class="mt-2 text-gray-600">{{ message }}</p>
|
||||
{% if detail %}
|
||||
<details class="mt-6 text-left border border-gray-200 rounded-lg">
|
||||
<summary class="px-4 py-2 text-sm text-gray-500 cursor-pointer hover:text-gray-700">Error details</summary>
|
||||
<pre class="px-4 py-3 text-xs text-red-700 bg-gray-50 overflow-x-auto whitespace-pre-wrap break-words border-t border-gray-200">{{ detail }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
<a href="/" class="inline-block mt-8 text-sm text-gray-500 hover:text-gray-700">← Back to home</a>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
{% from "components/timestamp.html.jinja" import timeago as ts %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-4xl mx-auto px-4 pt-12">
|
||||
@@ -57,7 +58,7 @@
|
||||
{{ member.role }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500">{{ member.joined_at or "—" }}</td>
|
||||
<td class="px-4 py-3 text-gray-500">{% if member.joined_at %}{{ ts(member.joined_at) }}{% else %}—{% endif %}</td>
|
||||
{% if is_admin %}
|
||||
<td class="px-4 py-3 text-right">
|
||||
{% if member.role != 'owner' %}
|
||||
|
||||
65
templates/pages/notifications.html.jinja
Normal file
65
templates/pages/notifications.html.jinja
Normal file
@@ -0,0 +1,65 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-4xl mx-auto px-4 pt-12 pb-12">
|
||||
<h1 class="text-2xl font-bold mb-8">Notifications</h1>
|
||||
|
||||
<div id="notifications-list">
|
||||
{% include "components/notifications_list.html.jinja" %}
|
||||
</div>
|
||||
</section>
|
||||
<script>
|
||||
(function() {
|
||||
const container = document.getElementById("notifications-list");
|
||||
if (!container) return;
|
||||
|
||||
// Track which release slugs the user has manually toggled open,
|
||||
// so we don't slam them shut on the next poll.
|
||||
const userToggled = new Set();
|
||||
container.addEventListener("toggle", function(e) {
|
||||
const details = e.target;
|
||||
if (details.tagName !== "DETAILS") return;
|
||||
const slug = details.dataset.slug;
|
||||
if (!slug) return;
|
||||
if (details.open) {
|
||||
userToggled.add(slug);
|
||||
} else {
|
||||
userToggled.delete(slug);
|
||||
}
|
||||
}, true);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const res = await fetch("/notifications?_partial=1", { credentials: "same-origin" });
|
||||
if (!res.ok) return;
|
||||
const html = await res.text();
|
||||
container.innerHTML = html;
|
||||
// Re-open any releases the user manually toggled open.
|
||||
userToggled.forEach(function(slug) {
|
||||
const el = container.querySelector('details[data-slug="' + slug + '"]');
|
||||
if (el) el.open = true;
|
||||
});
|
||||
// Clean up slugs no longer in the DOM.
|
||||
userToggled.forEach(function(slug) {
|
||||
if (!container.querySelector('details[data-slug="' + slug + '"]')) {
|
||||
userToggled.delete(slug);
|
||||
}
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
let timer = setInterval(refresh, 10000);
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
} else {
|
||||
// Immediate refresh on tab focus, then resume interval.
|
||||
refresh();
|
||||
timer = setInterval(refresh, 10000);
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
125
templates/pages/pipelines.html.jinja
Normal file
125
templates/pages/pipelines.html.jinja
Normal file
@@ -0,0 +1,125 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
{% from "components/timestamp.html.jinja" import timeago as ts %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-5xl mx-auto px-4 pt-12">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Release Pipelines</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
<a href="/orgs/{{ current_org }}/projects/{{ current_project }}" class="hover:underline">{{ current_project }}</a>
|
||||
· Define multi-stage deployment pipelines as a DAG
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if pipelines | length > 0 %}
|
||||
<div class="space-y-3 mb-8">
|
||||
{% for pipeline in pipelines %}
|
||||
<details class="border border-gray-200 rounded-lg overflow-hidden group">
|
||||
<summary class="px-4 py-3 flex items-center gap-3 flex-wrap cursor-pointer list-none hover:bg-gray-50">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
{% if pipeline.enabled %}
|
||||
<span class="inline-block w-2.5 h-2.5 rounded-full bg-green-500 shrink-0" title="Enabled"></span>
|
||||
{% else %}
|
||||
<span class="inline-block w-2.5 h-2.5 rounded-full bg-gray-300 shrink-0" title="Disabled"></span>
|
||||
{% endif %}
|
||||
<span class="font-medium text-gray-900">{{ pipeline.name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 text-xs text-gray-500 shrink-0">
|
||||
{% if pipeline.stages_json %}
|
||||
<span class="font-mono">{{ pipeline.stage_count }} stage{{ "s" if pipeline.stage_count != 1 }}</span>
|
||||
{% else %}
|
||||
<span class="italic text-gray-400">no stages</span>
|
||||
{% endif %}
|
||||
{{ ts(pipeline.updated_at) }}
|
||||
</div>
|
||||
|
||||
{% if is_admin %}
|
||||
<div class="flex items-center gap-2 shrink-0" onclick="event.stopPropagation()">
|
||||
<form method="post" action="/orgs/{{ current_org }}/projects/{{ current_project }}/pipelines/{{ pipeline.name }}/toggle">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
{% if pipeline.enabled %}
|
||||
<button type="submit" class="text-xs px-2.5 py-1 rounded border border-gray-300 text-gray-600 hover:bg-gray-50">Disable</button>
|
||||
{% else %}
|
||||
<input type="hidden" name="enabled" value="true">
|
||||
<button type="submit" class="text-xs px-2.5 py-1 rounded border border-green-300 text-green-700 hover:bg-green-50">Enable</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
<form method="post" action="/orgs/{{ current_org }}/projects/{{ current_project }}/pipelines/{{ pipeline.name }}/delete" onsubmit="return confirm('Delete pipeline "{{ pipeline.name }}"?')">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<button type="submit" class="text-xs px-2.5 py-1 rounded border border-red-300 text-red-600 hover:bg-red-50">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<svg class="w-4 h-4 text-gray-400 shrink-0 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
</summary>
|
||||
|
||||
<div class="border-t border-gray-100 px-4 py-3">
|
||||
{% if pipeline.stages_json %}
|
||||
{% if is_admin %}
|
||||
<form method="post" action="/orgs/{{ current_org }}/projects/{{ current_project }}/pipelines/{{ pipeline.name }}/update" class="space-y-3">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<pipeline-builder data-target="edit-{{ pipeline.name }}"></pipeline-builder>
|
||||
<textarea id="edit-{{ pipeline.name }}" name="stages_json" hidden>{{ pipeline.stages_json }}</textarea>
|
||||
<button type="submit" class="text-xs px-3 py-1.5 rounded bg-gray-900 text-white hover:bg-gray-800">Save Changes</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<pipeline-builder data-target="view-{{ pipeline.name }}" data-readonly="true"></pipeline-builder>
|
||||
<textarea id="view-{{ pipeline.name }}" hidden>{{ pipeline.stages_json }}</textarea>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if is_admin %}
|
||||
<form method="post" action="/orgs/{{ current_org }}/projects/{{ current_project }}/pipelines/{{ pipeline.name }}/update" class="space-y-3">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
<pipeline-builder data-target="edit-{{ pipeline.name }}"></pipeline-builder>
|
||||
<textarea id="edit-{{ pipeline.name }}" name="stages_json" hidden></textarea>
|
||||
<button type="submit" class="text-xs px-3 py-1.5 rounded bg-gray-900 text-white hover:bg-gray-800">Save Changes</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-400 italic">No stages configured yet.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="border border-dashed border-gray-300 rounded-lg p-8 text-center text-gray-500 mb-8">
|
||||
<p class="mb-1">No release pipelines configured.</p>
|
||||
{% if is_admin %}
|
||||
<p class="text-sm">Create one below to define a multi-stage deployment DAG.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_admin %}
|
||||
<div class="border border-gray-200 rounded-lg p-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Create Pipeline</h2>
|
||||
<form method="post" action="/orgs/{{ current_org }}/projects/{{ current_project }}/pipelines" class="space-y-4">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
|
||||
<div>
|
||||
<label for="pipeline-name" class="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input type="text" id="pipeline-name" name="name" required placeholder="e.g. deploy-to-production"
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-gray-900">
|
||||
<p class="text-xs text-gray-500 mt-1">A unique identifier for this pipeline.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Stages</label>
|
||||
<pipeline-builder data-target="pipeline-stages"></pipeline-builder>
|
||||
<textarea id="pipeline-stages" name="stages_json" hidden></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="bg-gray-900 text-white px-4 py-2 rounded-md text-sm hover:bg-gray-800 transition-colors">
|
||||
Create Pipeline
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
<script src="/static/js/pipeline-builder.js"></script>
|
||||
{% endblock %}
|
||||
175
templates/pages/policies.html.jinja
Normal file
175
templates/pages/policies.html.jinja
Normal file
@@ -0,0 +1,175 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-5xl mx-auto px-4 pt-12">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Deployment Policies</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
<a href="/orgs/{{ current_org }}/projects/{{ current_project }}" class="hover:underline">{{ current_project }}</a>
|
||||
· Gate deployments with soak times and branch restrictions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if policies | length > 0 %}
|
||||
<div class="space-y-3 mb-8">
|
||||
{% for policy in policies %}
|
||||
<div class="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div class="px-4 py-3 flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
{% if policy.enabled %}
|
||||
<span class="inline-block w-2.5 h-2.5 rounded-full bg-green-500 shrink-0" title="Enabled"></span>
|
||||
{% else %}
|
||||
<span class="inline-block w-2.5 h-2.5 rounded-full bg-gray-300 shrink-0" title="Disabled"></span>
|
||||
{% endif %}
|
||||
<span class="font-medium text-gray-900">{{ policy.name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5 text-xs text-gray-500 flex-wrap">
|
||||
{% if policy.policy_type == "soak_time" %}
|
||||
<span class="bg-indigo-100 text-indigo-700 px-1.5 py-0.5 rounded">Soak Time</span>
|
||||
<code class="bg-gray-100 px-1.5 py-0.5 rounded">{{ policy.config.source_environment }}</code>
|
||||
<span class="text-gray-300">→</span>
|
||||
<code class="bg-gray-100 px-1.5 py-0.5 rounded">{{ policy.config.target_environment }}</code>
|
||||
<span class="text-gray-400">·</span>
|
||||
<span>{{ policy.config.duration_human }}</span>
|
||||
{% elif policy.policy_type == "branch_restriction" %}
|
||||
<span class="bg-orange-100 text-orange-700 px-1.5 py-0.5 rounded">Branch Restriction</span>
|
||||
<code class="bg-gray-100 px-1.5 py-0.5 rounded">{{ policy.config.branch_pattern }}</code>
|
||||
<span class="text-gray-300">→</span>
|
||||
<code class="bg-gray-100 px-1.5 py-0.5 rounded">{{ policy.config.target_environment }}</code>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if is_admin %}
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<a href="/orgs/{{ current_org }}/projects/{{ current_project }}/policies/{{ policy.name }}" class="text-xs px-2.5 py-1 rounded border border-gray-300 text-gray-600 hover:bg-gray-50">Edit</a>
|
||||
<form method="post" action="/orgs/{{ current_org }}/projects/{{ current_project }}/policies/{{ policy.name }}/toggle">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
{% if policy.enabled %}
|
||||
<button type="submit" class="text-xs px-2.5 py-1 rounded border border-gray-300 text-gray-600 hover:bg-gray-50">Disable</button>
|
||||
{% else %}
|
||||
<input type="hidden" name="enabled" value="true">
|
||||
<button type="submit" class="text-xs px-2.5 py-1 rounded border border-green-300 text-green-700 hover:bg-green-50">Enable</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
<form method="post" action="/orgs/{{ current_org }}/projects/{{ current_project }}/policies/{{ policy.name }}/delete" onsubmit="return confirm('Delete policy "{{ policy.name }}"?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="text-xs px-2.5 py-1 rounded border border-red-300 text-red-600 hover:bg-red-50">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="border border-dashed border-gray-300 rounded-lg p-8 text-center text-gray-500 mb-8">
|
||||
<p class="mb-1">No deployment policies configured.</p>
|
||||
{% if is_admin %}
|
||||
<p class="text-sm">Create one below to gate deployments with soak times or branch restrictions.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_admin %}
|
||||
<div class="border border-gray-200 rounded-lg p-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Create Policy</h2>
|
||||
<form method="post" action="/orgs/{{ current_org }}/projects/{{ current_project }}/policies" class="space-y-4" id="policy-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div>
|
||||
<label for="policy-name" class="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input type="text" id="policy-name" name="name" required placeholder="e.g. staging-soak-30m"
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-gray-900">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="policy-type" class="block text-sm font-medium text-gray-700 mb-1">Type</label>
|
||||
<select id="policy-type" name="policy_type"
|
||||
class="border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-gray-900">
|
||||
<option value="soak_time">Soak Time</option>
|
||||
<option value="branch_restriction">Branch Restriction</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1" id="policy-type-desc">
|
||||
Require an artifact to succeed in a source environment for a duration before deploying to target.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# Soak Time fields #}
|
||||
<div id="soak-time-fields">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Source environment</label>
|
||||
<select name="source_environment" class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white">
|
||||
{% for env in environments %}
|
||||
<option value="{{ env.name }}">{{ env.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Target environment</label>
|
||||
<select name="target_environment" id="soak-target-env" class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white">
|
||||
{% for env in environments %}
|
||||
<option value="{{ env.name }}">{{ env.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Soak duration (seconds)</label>
|
||||
<input type="number" name="duration_seconds" min="1" placeholder="1800"
|
||||
class="w-48 border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-gray-900">
|
||||
<p class="text-xs text-gray-500 mt-1">e.g. 1800 = 30 minutes, 3600 = 1 hour</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Branch Restriction fields #}
|
||||
<div id="branch-restriction-fields" class="hidden">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Branch pattern</label>
|
||||
<input type="text" name="branch_pattern" placeholder="e.g. main"
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-gray-900">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Target environment</label>
|
||||
<select name="target_environment" id="branch-target-env" class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white">
|
||||
{% for env in environments %}
|
||||
<option value="{{ env.name }}">{{ env.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="bg-gray-900 text-white px-4 py-2 rounded-md text-sm hover:bg-gray-800 transition-colors">
|
||||
Create Policy
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const typeSelect = document.getElementById('policy-type');
|
||||
const soakFields = document.getElementById('soak-time-fields');
|
||||
const branchFields = document.getElementById('branch-restriction-fields');
|
||||
const desc = document.getElementById('policy-type-desc');
|
||||
|
||||
const descriptions = {
|
||||
soak_time: 'Require an artifact to succeed in a source environment for a duration before deploying to target.',
|
||||
branch_restriction: 'Only allow deployments to the target environment from a specific branch pattern.',
|
||||
};
|
||||
|
||||
typeSelect.addEventListener('change', () => {
|
||||
const isSoak = typeSelect.value === 'soak_time';
|
||||
soakFields.classList.toggle('hidden', !isSoak);
|
||||
branchFields.classList.toggle('hidden', isSoak);
|
||||
desc.textContent = descriptions[typeSelect.value] || '';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
81
templates/pages/policy_edit.html.jinja
Normal file
81
templates/pages/policy_edit.html.jinja
Normal file
@@ -0,0 +1,81 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-5xl mx-auto px-4 pt-12">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 mb-2">
|
||||
<a href="/orgs/{{ current_org }}/projects/{{ current_project }}/policies" class="hover:underline">Policies</a>
|
||||
<span>›</span>
|
||||
<span>{{ policy.name }}</span>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold">Edit Policy</h1>
|
||||
</div>
|
||||
|
||||
<div class="border border-gray-200 rounded-lg p-6">
|
||||
<form method="post" action="/orgs/{{ current_org }}/projects/{{ current_project }}/policies/{{ policy.name }}" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="policy_type" value="{{ policy.policy_type }}">
|
||||
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 mb-2">
|
||||
<span>Type:</span>
|
||||
{% if policy.policy_type == "soak_time" %}
|
||||
<span class="bg-indigo-100 text-indigo-700 px-1.5 py-0.5 rounded text-xs font-medium">Soak Time</span>
|
||||
{% elif policy.policy_type == "branch_restriction" %}
|
||||
<span class="bg-orange-100 text-orange-700 px-1.5 py-0.5 rounded text-xs font-medium">Branch Restriction</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if policy.policy_type == "soak_time" %}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Source environment</label>
|
||||
<select name="source_environment" class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white">
|
||||
{% for env in environments %}
|
||||
<option value="{{ env.name }}" {% if env.name == policy.config.source_environment %}selected{% endif %}>{{ env.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Target environment</label>
|
||||
<select name="target_environment" class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white">
|
||||
{% for env in environments %}
|
||||
<option value="{{ env.name }}" {% if env.name == policy.config.target_environment %}selected{% endif %}>{{ env.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Soak duration (seconds)</label>
|
||||
<input type="number" name="duration_seconds" min="1" value="{{ policy.config.duration_seconds }}"
|
||||
class="w-48 border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-gray-900">
|
||||
<p class="text-xs text-gray-500 mt-1">e.g. 1800 = 30 minutes, 3600 = 1 hour</p>
|
||||
</div>
|
||||
|
||||
{% elif policy.policy_type == "branch_restriction" %}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Branch pattern</label>
|
||||
<input type="text" name="branch_pattern" value="{{ policy.config.branch_pattern }}"
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-gray-900">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Target environment</label>
|
||||
<select name="target_environment" class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm bg-white">
|
||||
{% for env in environments %}
|
||||
<option value="{{ env.name }}" {% if env.name == policy.config.target_environment %}selected{% endif %}>{{ env.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<button type="submit" class="bg-gray-900 text-white px-4 py-2 rounded-md text-sm hover:bg-gray-800 transition-colors">
|
||||
Save Changes
|
||||
</button>
|
||||
<a href="/orgs/{{ current_org }}/projects/{{ current_project }}/policies" class="text-sm text-gray-500 hover:text-gray-900">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,65 +1,26 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-4xl mx-auto px-4 pt-12">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<a href="/orgs/{{ org_name }}/projects" class="text-sm text-gray-500 hover:text-gray-700">← {{ org_name }}</a>
|
||||
<h1 class="text-2xl font-bold mt-1">{{ project_name }}</h1>
|
||||
<section class="px-4 pt-12">
|
||||
<div class="max-w-5xl mx-auto flex items-center justify-between mb-8">
|
||||
<h1 class="text-2xl font-bold">Continuous deployment</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/orgs/{{ org_name }}/projects/{{ project_name }}/pipelines" class="text-sm text-gray-500 hover:text-gray-900 flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16"/></svg>
|
||||
Pipelines
|
||||
</a>
|
||||
<a href="/orgs/{{ org_name }}/projects/{{ project_name }}/triggers" class="text-sm text-gray-500 hover:text-gray-900 flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
Triggers
|
||||
</a>
|
||||
<a href="/orgs/{{ org_name }}/projects/{{ project_name }}/policies" class="text-sm text-gray-500 hover:text-gray-900 flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>
|
||||
Policies
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="font-bold text-lg mb-4">Recent releases</h2>
|
||||
|
||||
{% if artifacts %}
|
||||
<div class="space-y-4">
|
||||
{% for artifact in artifacts %}
|
||||
<div class="p-4 border border-gray-200 rounded-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/orgs/{{ org_name }}/projects/{{ project_name }}/releases/{{ artifact.slug }}" class="font-medium text-gray-900 hover:text-blue-600">{{ artifact.title }}</a>
|
||||
{% if artifact.version %}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">{{ artifact.version }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if artifact.description %}
|
||||
<p class="text-sm text-gray-600 mt-1">{{ artifact.description }}</p>
|
||||
{% endif %}
|
||||
{% if artifact.branch or artifact.commit_sha %}
|
||||
<div class="flex items-center gap-2 mt-2 text-xs text-gray-500">
|
||||
{% if artifact.branch %}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded bg-blue-50 text-blue-700 font-mono">{{ artifact.branch }}</span>
|
||||
{% endif %}
|
||||
{% if artifact.commit_sha %}
|
||||
<span class="font-mono">{{ artifact.commit_sha[:8] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if artifact.source_user %}
|
||||
<p class="text-xs text-gray-400 mt-1">by {{ artifact.source_user }}{% if artifact.source_type %} via {{ artifact.source_type }}{% endif %}</p>
|
||||
{% endif %}
|
||||
{% if artifact.destinations %}
|
||||
<div class="flex gap-1.5 mt-2">
|
||||
{% for dest in artifact.destinations %}
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600">{{ dest.name }} ({{ dest.environment }})</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-right text-sm text-gray-500 shrink-0 ml-4">
|
||||
<p class="font-mono">{{ artifact.slug }}</p>
|
||||
<p>{{ artifact.created_at }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-6 border border-gray-200 rounded-lg text-center">
|
||||
<p class="text-gray-600">No releases yet.</p>
|
||||
<p class="text-sm text-gray-400 mt-2">Create a release with <code class="bg-gray-100 px-1 rounded">forest release create</code></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<release-timeline org="{{ org_name }}" project="{{ project_name }}"></release-timeline>
|
||||
</section>
|
||||
<script src="/static/js/components/forage-components.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
89
templates/pages/project_releases.html.jinja
Normal file
89
templates/pages/project_releases.html.jinja
Normal file
@@ -0,0 +1,89 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
{% from "components/timestamp.html.jinja" import timeago as ts %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-4xl mx-auto px-4 pt-12">
|
||||
<h1 class="text-2xl font-bold mb-8">Releases</h1>
|
||||
|
||||
{% if releases %}
|
||||
<div class="border border-gray-200 rounded-lg divide-y divide-gray-200">
|
||||
{% for release in releases %}
|
||||
<div class="px-5 py-4 hover:bg-gray-50">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/orgs/{{ org_name }}/projects/{{ project_name }}/releases/{{ release.slug }}" class="font-medium text-gray-900 truncate hover:underline">{{ release.title }}</a>
|
||||
{% if release.status == "deployed" %}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-green-100 text-green-800">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500"></span>
|
||||
Deployed
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-gray-400"></span>
|
||||
Pending
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-1.5 mt-1.5">
|
||||
{% if release.branch %}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-blue-50 text-blue-700 border border-blue-200">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg>
|
||||
{{ release.branch }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if release.commit_sha %}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-mono px-2 py-0.5 rounded-full bg-gray-50 text-gray-600 border border-gray-200">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/></svg>
|
||||
{{ release.commit_sha[:7] }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if release.version %}
|
||||
<span class="inline-flex items-center text-xs font-medium px-2 py-0.5 rounded-full bg-green-50 text-green-700 border border-green-200">
|
||||
{{ release.version }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if release.source_type %}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-purple-50 text-purple-700 border border-purple-200">
|
||||
{% if 'github' in release.source_type %}
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
{% elif 'gitlab' in release.source_type %}
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 01-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 014.82 2a.43.43 0 01.58 0 .42.42 0 01.11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0118.6 2a.43.43 0 01.58 0 .42.42 0 01.11.18l2.44 7.51L23 13.45a.84.84 0 01-.35.94z"/></svg>
|
||||
{% else %}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
{% endif %}
|
||||
{{ release.source_type | replace("_", " ") | replace("-", " ") }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if release.source_user %}
|
||||
<a href="/users/{{ release.source_user }}" class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-amber-50 text-amber-700 border border-amber-200 hover:bg-amber-100 transition-colors">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
|
||||
{{ release.source_user }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 shrink-0">
|
||||
{% if release.envs %}
|
||||
<div class="flex gap-1">
|
||||
{% for env in release.envs %}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {% if 'prod' in env and 'preprod' not in env %}bg-pink-100 text-pink-800{% elif 'preprod' in env or 'pre-prod' in env %}bg-orange-100 text-orange-800{% elif 'stag' in env %}bg-yellow-100 text-yellow-800{% elif 'dev' in env %}bg-violet-100 text-violet-800{% else %}bg-gray-100 text-gray-700{% endif %}">
|
||||
{{ env }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ ts(release.created_at, class="text-xs text-gray-400") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-6 border border-gray-200 rounded-lg text-center">
|
||||
<p class="text-gray-600">No releases yet.</p>
|
||||
<p class="text-sm text-gray-400 mt-2">Create a release with <code class="bg-gray-100 px-1 rounded">forest release create</code></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,152 +1,12 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-5xl mx-auto px-4 py-12">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<section class="px-4 py-12">
|
||||
<div class="max-w-5xl mx-auto flex items-center justify-between mb-8">
|
||||
<h1 class="text-2xl font-bold">Continuous deployment</h1>
|
||||
</div>
|
||||
|
||||
{% if timeline | length > 0 %}
|
||||
<swim-lanes>
|
||||
{# ── Environment swim lanes ──────────────────────────────── #}
|
||||
{% for lane in lanes %}
|
||||
<div data-lane="{{ lane.name }}"></div>
|
||||
{% endfor %}
|
||||
|
||||
{# ── Release timeline ─────────────────────────────────────── #}
|
||||
<div data-swimlane-timeline class="flex-1 space-y-3 min-w-0 ml-2">
|
||||
{% for item in timeline %}
|
||||
|
||||
{% if item.kind == "release" %}
|
||||
{# ── Visible release card ──────────────────────────────── #}
|
||||
{% set release = item.release %}
|
||||
<div data-release data-envs="{{ release.dest_envs }}" class="border border-gray-200 rounded-lg overflow-hidden">
|
||||
{# Release header #}
|
||||
<div class="px-4 py-3 flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span class="inline-block w-6 h-6 rounded-full bg-gray-200 shrink-0" data-avatar></span>
|
||||
<a href="/orgs/{{ org_name }}/projects/{{ release.project_name }}/releases/{{ release.slug }}" class="font-medium text-gray-900 hover:text-black truncate">
|
||||
{{ release.title }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-xs text-gray-500 shrink-0 flex-wrap">
|
||||
{% if release.branch %}
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z"/></svg>
|
||||
{{ release.branch }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if release.commit_sha %}
|
||||
<span class="font-mono">{{ release.commit_sha[:7] }}</span>
|
||||
{% endif %}
|
||||
<span title="{{ release.created_at | datetime }}">{{ release.created_at | timeago }}</span>
|
||||
{% if release.source_user %}
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
|
||||
{{ release.source_user }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="text-gray-400">
|
||||
<a href="/orgs/{{ org_name }}/projects/{{ release.project_name }}" class="hover:text-gray-700">{{ release.project_name }}</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Deployment steps (collapsed by default) #}
|
||||
<details class="border-t border-gray-100 group">
|
||||
<summary class="px-4 py-2 flex items-center gap-2 text-sm cursor-pointer list-none hover:bg-gray-50">
|
||||
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<span class="text-gray-600 text-sm">Deployed to</span>
|
||||
{% for dest in release.destinations %}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {% if 'prod' in dest.environment and 'preprod' not in dest.environment %}bg-pink-100 text-pink-800{% elif 'preprod' in dest.environment or 'pre-prod' in dest.environment %}bg-orange-100 text-orange-800{% elif 'stag' in dest.environment %}bg-yellow-100 text-yellow-800{% elif 'dev' in dest.environment %}bg-violet-100 text-violet-800{% else %}bg-gray-100 text-gray-700{% endif %}">
|
||||
{{ dest.environment }}
|
||||
<span class="w-1.5 h-1.5 rounded-full {% if 'prod' in dest.environment and 'preprod' not in dest.environment %}bg-pink-500{% elif 'preprod' in dest.environment or 'pre-prod' in dest.environment %}bg-orange-500{% elif 'stag' in dest.environment %}bg-yellow-500{% elif 'dev' in dest.environment %}bg-violet-500{% else %}bg-gray-400{% endif %}"></span>
|
||||
</span>
|
||||
{% endfor %}
|
||||
<svg class="w-3 h-3 text-gray-400 shrink-0 ml-auto transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
</summary>
|
||||
{% for dest in release.destinations %}
|
||||
<div class="px-4 py-2 flex items-center gap-3 text-sm {% if not loop.last %}border-b border-gray-50{% endif %} border-t border-gray-50">
|
||||
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<span class="text-gray-600">Deployed to</span>
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {% if 'prod' in dest.environment and 'preprod' not in dest.environment %}bg-pink-100 text-pink-800{% elif 'preprod' in dest.environment or 'pre-prod' in dest.environment %}bg-orange-100 text-orange-800{% elif 'stag' in dest.environment %}bg-yellow-100 text-yellow-800{% elif 'dev' in dest.environment %}bg-violet-100 text-violet-800{% else %}bg-gray-100 text-gray-700{% endif %}">
|
||||
{{ dest.environment }}
|
||||
<span class="w-1.5 h-1.5 rounded-full {% if 'prod' in dest.environment and 'preprod' not in dest.environment %}bg-pink-500{% elif 'preprod' in dest.environment or 'pre-prod' in dest.environment %}bg-orange-500{% elif 'stag' in dest.environment %}bg-yellow-500{% elif 'dev' in dest.environment %}bg-violet-500{% else %}bg-gray-400{% endif %}"></span>
|
||||
</span>
|
||||
<span class="text-gray-400 text-xs">{{ dest.name }}</span>
|
||||
{% if dest.type_name %}
|
||||
<span class="text-gray-400 text-xs">({{ dest.type_name }}{% if dest.type_version %} v{{ dest.type_version }}{% endif %})</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{% elif item.kind == "hidden" %}
|
||||
{# ── Hidden commits group ──────────────────────────────── #}
|
||||
<details class="group">
|
||||
<summary class="flex items-center gap-2 py-2 px-1 text-sm text-gray-400 cursor-pointer hover:text-gray-600 list-none">
|
||||
<svg class="w-3 h-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
{{ item.count }} hidden commit{{ "s" if item.count != 1 }}
|
||||
<span class="text-gray-300">·</span>
|
||||
<span class="group-open:hidden">Show commit{{ "s" if item.count != 1 }}</span>
|
||||
<span class="hidden group-open:inline">Hide commit{{ "s" if item.count != 1 }}</span>
|
||||
</summary>
|
||||
<div class="space-y-3 mt-1">
|
||||
{% for release in item.releases %}
|
||||
<div data-release data-envs="" class="border border-gray-200 rounded-lg overflow-hidden opacity-75">
|
||||
<div class="px-4 py-3 flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span class="inline-block w-6 h-6 rounded-full bg-gray-200 shrink-0" data-avatar></span>
|
||||
<a href="/orgs/{{ org_name }}/projects/{{ release.project_name }}/releases/{{ release.slug }}" class="font-medium text-gray-900 hover:text-black truncate">
|
||||
{{ release.title }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-xs text-gray-500 shrink-0 flex-wrap">
|
||||
{% if release.commit_sha %}
|
||||
<span class="font-mono">{{ release.commit_sha[:7] }}</span>
|
||||
{% endif %}
|
||||
<span title="{{ release.created_at | datetime }}">{{ release.created_at | timeago }}</span>
|
||||
{% if release.source_user %}
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
|
||||
{{ release.source_user }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="text-gray-400">
|
||||
<a href="/orgs/{{ org_name }}/projects/{{ release.project_name }}" class="hover:text-gray-700">{{ release.project_name }}</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
</swim-lanes>
|
||||
|
||||
{% else %}
|
||||
{# ── Empty state ──────────────────────────────────────────── #}
|
||||
<div class="border border-gray-200 rounded-lg p-12 text-center">
|
||||
<div class="max-w-md mx-auto">
|
||||
<div class="w-12 h-12 mx-auto mb-4 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"/></svg>
|
||||
</div>
|
||||
<p class="font-medium text-gray-900 mb-1">No releases yet</p>
|
||||
<p class="text-sm text-gray-500 mb-6">Releases appear when you deploy with Forest CLI.</p>
|
||||
<div class="bg-gray-50 rounded-lg p-4 text-left">
|
||||
<p class="text-xs font-medium text-gray-700 mb-2">Get started with the CLI:</p>
|
||||
<pre class="text-xs text-gray-600 overflow-x-auto"><code>forest release create \
|
||||
--org {{ org_name }} \
|
||||
--project my-project \
|
||||
--dest staging:my-service</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<release-timeline org="{{ org_name }}"></release-timeline>
|
||||
</section>
|
||||
<script src="/static/js/swim-lanes.js"></script>
|
||||
<script src="/static/js/components/forage-components.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
{% from "components/timestamp.html.jinja" import timeago as ts %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-4xl mx-auto px-4 pt-12">
|
||||
@@ -50,9 +51,9 @@
|
||||
{% for token in tokens %}
|
||||
<tr>
|
||||
<td class="px-6 py-3 font-medium">{{ token.name }}</td>
|
||||
<td class="px-6 py-3 text-gray-600">{{ token.created_at or "—" }}</td>
|
||||
<td class="px-6 py-3 text-gray-600">{{ token.last_used or "Never" }}</td>
|
||||
<td class="px-6 py-3 text-gray-600">{{ token.expires_at or "Never" }}</td>
|
||||
<td class="px-6 py-3 text-gray-600">{% if token.created_at %}{{ ts(token.created_at) }}{% else %}—{% endif %}</td>
|
||||
<td class="px-6 py-3 text-gray-600">{% if token.last_used %}{{ ts(token.last_used) }}{% else %}Never{% endif %}</td>
|
||||
<td class="px-6 py-3 text-gray-600">{% if token.expires_at %}{{ ts(token.expires_at) }}{% else %}Never{% endif %}</td>
|
||||
<td class="px-6 py-3 text-right">
|
||||
<form method="POST" action="/settings/tokens/{{ token.token_id }}/delete">
|
||||
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
|
||||
|
||||
95
templates/pages/trigger_edit.html.jinja
Normal file
95
templates/pages/trigger_edit.html.jinja
Normal file
@@ -0,0 +1,95 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-5xl mx-auto px-4 pt-12">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 mb-2">
|
||||
<a href="/orgs/{{ current_org }}/projects/{{ current_project }}/triggers" class="hover:underline">Triggers</a>
|
||||
<span>›</span>
|
||||
<span>{{ trigger.name }}</span>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold">Edit Trigger</h1>
|
||||
</div>
|
||||
|
||||
<div class="border border-gray-200 rounded-lg p-6">
|
||||
<form method="post" action="/orgs/{{ current_org }}/projects/{{ current_project }}/triggers/{{ trigger.name }}" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<p class="text-sm text-gray-500 mb-2">Update filter patterns. Artifacts matching <strong>all</strong> patterns will be auto-released.</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Branch pattern</label>
|
||||
<input type="text" name="branch_pattern" value="{{ trigger.branch_pattern }}" placeholder="e.g. main"
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-gray-900">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Title pattern</label>
|
||||
<input type="text" name="title_pattern" value="{{ trigger.title_pattern }}" placeholder="e.g. ^Release.*"
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-gray-900">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Author pattern</label>
|
||||
<input type="text" name="author_pattern" value="{{ trigger.author_pattern }}" placeholder="e.g. ci-bot"
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-gray-900">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Commit message pattern</label>
|
||||
<input type="text" name="commit_message_pattern" value="{{ trigger.commit_message_pattern }}" placeholder="e.g. ^Merge pull request.*"
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-gray-900">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Source type pattern</label>
|
||||
<input type="text" name="source_type_pattern" value="{{ trigger.source_type_pattern }}" placeholder="e.g. github-actions"
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-gray-900">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="border-gray-200">
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Target environments</label>
|
||||
{% if environments | length > 0 %}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for env in environments %}
|
||||
<label class="inline-flex items-center gap-1.5 text-sm border border-gray-200 rounded-md px-3 py-1.5 cursor-pointer hover:bg-gray-50 has-[:checked]:border-blue-500 has-[:checked]:bg-blue-50">
|
||||
<input type="checkbox" name="target_environments" value="{{ env.name }}"
|
||||
{% if env.name in trigger.target_environments %}checked{% endif %}
|
||||
class="rounded border-gray-300 text-blue-600">
|
||||
{{ env.name }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-400">No environments configured.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if pipelines | length > 0 %}
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" id="use-pipeline" name="use_pipeline" value="true"
|
||||
{% if trigger.use_pipeline %}checked{% endif %}
|
||||
class="rounded border-gray-300">
|
||||
<label for="use-pipeline" class="text-sm font-medium text-gray-700">Use release pipeline</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" id="force-release" name="force_release" value="true"
|
||||
{% if trigger.force_release %}checked{% endif %}
|
||||
class="rounded border-gray-300">
|
||||
<label for="force-release" class="text-sm text-gray-700">Force release (skip queue)</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 pt-2">
|
||||
<button type="submit" class="bg-gray-900 text-white px-4 py-2 rounded-md text-sm hover:bg-gray-800 transition-colors">
|
||||
Save Changes
|
||||
</button>
|
||||
<a href="/orgs/{{ current_org }}/projects/{{ current_project }}/triggers" class="text-sm text-gray-500 hover:text-gray-900">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
265
templates/pages/triggers.html.jinja
Normal file
265
templates/pages/triggers.html.jinja
Normal file
@@ -0,0 +1,265 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-5xl mx-auto px-4 pt-12">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Triggers</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
<a href="/orgs/{{ current_org }}/projects/{{ current_project }}" class="hover:underline">{{ current_project }}</a>
|
||||
· Automatically release artifacts matching these rules
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if triggers | length > 0 %}
|
||||
<div class="space-y-3 mb-8">
|
||||
{% for trigger in triggers %}
|
||||
<div class="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div class="px-4 py-3 flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
{% if trigger.enabled %}
|
||||
<span class="inline-block w-2.5 h-2.5 rounded-full bg-green-500 shrink-0" title="Enabled"></span>
|
||||
{% else %}
|
||||
<span class="inline-block w-2.5 h-2.5 rounded-full bg-gray-300 shrink-0" title="Disabled"></span>
|
||||
{% endif %}
|
||||
<span class="font-medium text-gray-900">{{ trigger.name }}</span>
|
||||
</div>
|
||||
|
||||
{# Summary of patterns #}
|
||||
<div class="flex items-center gap-1.5 text-xs text-gray-500 flex-wrap">
|
||||
{% if trigger.branch_pattern %}
|
||||
<code class="bg-gray-100 px-1.5 py-0.5 rounded">branch: {{ trigger.branch_pattern }}</code>
|
||||
{% endif %}
|
||||
{% if trigger.title_pattern %}
|
||||
<code class="bg-gray-100 px-1.5 py-0.5 rounded">title: {{ trigger.title_pattern }}</code>
|
||||
{% endif %}
|
||||
{% if trigger.author_pattern %}
|
||||
<code class="bg-gray-100 px-1.5 py-0.5 rounded">author: {{ trigger.author_pattern }}</code>
|
||||
{% endif %}
|
||||
{% if trigger.commit_message_pattern %}
|
||||
<code class="bg-gray-100 px-1.5 py-0.5 rounded">msg: {{ trigger.commit_message_pattern }}</code>
|
||||
{% endif %}
|
||||
{% if trigger.source_type_pattern %}
|
||||
<code class="bg-gray-100 px-1.5 py-0.5 rounded">src: {{ trigger.source_type_pattern }}</code>
|
||||
{% endif %}
|
||||
<span class="text-gray-300">→</span>
|
||||
{% for env in trigger.target_environments %}
|
||||
<span class="bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded">{{ env }}</span>
|
||||
{% endfor %}
|
||||
{% if trigger.use_pipeline %}
|
||||
<span class="bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded">pipeline</span>
|
||||
{% endif %}
|
||||
{% if trigger.force_release %}
|
||||
<span class="bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded">force</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if is_admin %}
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<a href="/orgs/{{ current_org }}/projects/{{ current_project }}/triggers/{{ trigger.name }}" class="text-xs px-2.5 py-1 rounded border border-gray-300 text-gray-600 hover:bg-gray-50">Edit</a>
|
||||
<form method="post" action="/orgs/{{ current_org }}/projects/{{ current_project }}/triggers/{{ trigger.name }}/toggle">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
{% if trigger.enabled %}
|
||||
<button type="submit" class="text-xs px-2.5 py-1 rounded border border-gray-300 text-gray-600 hover:bg-gray-50">Disable</button>
|
||||
{% else %}
|
||||
<input type="hidden" name="enabled" value="true">
|
||||
<button type="submit" class="text-xs px-2.5 py-1 rounded border border-green-300 text-green-700 hover:bg-green-50">Enable</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
<form method="post" action="/orgs/{{ current_org }}/projects/{{ current_project }}/triggers/{{ trigger.name }}/delete" onsubmit="return confirm('Delete trigger "{{ trigger.name }}"?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="text-xs px-2.5 py-1 rounded border border-red-300 text-red-600 hover:bg-red-50">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="border border-dashed border-gray-300 rounded-lg p-8 text-center text-gray-500 mb-8">
|
||||
<p class="mb-1">No triggers configured.</p>
|
||||
{% if is_admin %}
|
||||
<p class="text-sm">Create one below to automatically release matching artifacts.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_admin %}
|
||||
<div class="border border-gray-200 rounded-lg p-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Create Trigger</h2>
|
||||
<form method="post" action="/orgs/{{ current_org }}/projects/{{ current_project }}/triggers" class="space-y-4" id="trigger-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="name" id="trigger-name-hidden" value="">
|
||||
|
||||
<p class="text-sm text-gray-500 mb-2">Add at least one filter pattern. Artifacts matching <strong>all</strong> patterns will be auto-released.</p>
|
||||
|
||||
{# Pattern rows #}
|
||||
<div class="space-y-3" id="pattern-rows">
|
||||
</div>
|
||||
|
||||
<button type="button" id="add-pattern-btn" class="text-sm text-gray-500 hover:text-gray-900 flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
|
||||
Add filter
|
||||
</button>
|
||||
|
||||
<hr class="border-gray-200">
|
||||
|
||||
{# Target environments #}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Target environments</label>
|
||||
{% if environments | length > 0 %}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for env in environments %}
|
||||
<label class="inline-flex items-center gap-1.5 text-sm border border-gray-200 rounded-md px-3 py-1.5 cursor-pointer hover:bg-gray-50 has-[:checked]:border-blue-500 has-[:checked]:bg-blue-50">
|
||||
<input type="checkbox" name="target_environments" value="{{ env.name }}" class="rounded border-gray-300 text-blue-600">
|
||||
{{ env.name }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-400">No environments configured. <a href="/orgs/{{ current_org }}/destinations" class="underline">Create one first.</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Pipeline selection #}
|
||||
{% if pipelines | length > 0 %}
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<input type="checkbox" id="use-pipeline" name="use_pipeline" value="true"
|
||||
class="rounded border-gray-300"
|
||||
onchange="document.getElementById('pipeline-select-row').style.display = this.checked ? '' : 'none'">
|
||||
<label for="use-pipeline" class="text-sm font-medium text-gray-700">Use release pipeline</label>
|
||||
</div>
|
||||
<div id="pipeline-select-row" class="ml-6 hidden">
|
||||
<p class="text-xs text-gray-500 mb-1">Releases matching this trigger will use the selected pipeline's deployment stages.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Options #}
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" id="force-release" name="force_release" value="true"
|
||||
class="rounded border-gray-300">
|
||||
<label for="force-release" class="text-sm text-gray-700">Force release (skip queue)</label>
|
||||
</div>
|
||||
|
||||
{# Auto-generated name preview #}
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>Name:</span>
|
||||
<span id="trigger-name-preview" class="font-mono text-gray-700"></span>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="bg-gray-900 text-white px-4 py-2 rounded-md text-sm hover:bg-gray-800 transition-colors">
|
||||
Create Trigger
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const PATTERN_TYPES = [
|
||||
{ value: 'branch_pattern', label: 'Branch', placeholder: 'e.g. main' },
|
||||
{ value: 'title_pattern', label: 'Title', placeholder: 'e.g. ^Release.*' },
|
||||
{ value: 'author_pattern', label: 'Author', placeholder: 'e.g. ci-bot' },
|
||||
{ value: 'commit_message_pattern', label: 'Commit message', placeholder: 'e.g. ^Merge pull request.*' },
|
||||
{ value: 'source_type_pattern', label: 'Source type', placeholder: 'e.g. github-actions' },
|
||||
];
|
||||
|
||||
const rows = document.getElementById('pattern-rows');
|
||||
const addBtn = document.getElementById('add-pattern-btn');
|
||||
const nameHidden = document.getElementById('trigger-name-hidden');
|
||||
const namePreview = document.getElementById('trigger-name-preview');
|
||||
|
||||
function updateName() {
|
||||
const parts = [];
|
||||
rows.querySelectorAll('.pattern-row').forEach(row => {
|
||||
const val = row.querySelector('input[type="text"]')?.value?.trim();
|
||||
if (val) parts.push(val);
|
||||
});
|
||||
const envs = [];
|
||||
document.querySelectorAll('input[name="target_environments"]:checked').forEach(cb => {
|
||||
envs.push(cb.value);
|
||||
});
|
||||
let name = '';
|
||||
if (parts.length > 0 && envs.length > 0) {
|
||||
name = parts[0] + '-to-' + envs.join('-');
|
||||
} else if (parts.length > 0) {
|
||||
name = parts[0];
|
||||
}
|
||||
nameHidden.value = name;
|
||||
namePreview.textContent = name || '(auto-generated from pattern)';
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
// Find which types are already used
|
||||
const used = new Set();
|
||||
rows.querySelectorAll('select').forEach(sel => used.add(sel.value));
|
||||
const available = PATTERN_TYPES.filter(t => !used.has(t.value));
|
||||
if (available.length === 0) return;
|
||||
|
||||
const type = available[0];
|
||||
const row = document.createElement('div');
|
||||
row.className = 'pattern-row flex items-center gap-2';
|
||||
row.innerHTML = `
|
||||
<select name="_pattern_type" class="border border-gray-300 rounded-md px-2 py-1.5 text-sm bg-white min-w-[140px]">
|
||||
${PATTERN_TYPES.map(t =>
|
||||
`<option value="${t.value}" ${t.value === type.value ? 'selected' : ''} ${used.has(t.value) && t.value !== type.value ? 'disabled' : ''}>${t.label}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
<input type="text" name="${type.value}" placeholder="${type.placeholder}"
|
||||
class="flex-1 border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 font-mono">
|
||||
<button type="button" class="remove-btn text-gray-400 hover:text-red-500 p-1" title="Remove">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
`;
|
||||
|
||||
const select = row.querySelector('select');
|
||||
const input = row.querySelector('input[type="text"]');
|
||||
|
||||
select.addEventListener('change', () => {
|
||||
const newType = PATTERN_TYPES.find(t => t.value === select.value);
|
||||
input.name = select.value;
|
||||
input.placeholder = newType?.placeholder || '';
|
||||
updateName();
|
||||
updateAvailable();
|
||||
});
|
||||
input.addEventListener('input', updateName);
|
||||
row.querySelector('.remove-btn').addEventListener('click', () => {
|
||||
row.remove();
|
||||
updateName();
|
||||
updateAvailable();
|
||||
});
|
||||
|
||||
rows.appendChild(row);
|
||||
input.focus();
|
||||
updateName();
|
||||
updateAvailable();
|
||||
}
|
||||
|
||||
function updateAvailable() {
|
||||
const used = new Set();
|
||||
rows.querySelectorAll('select').forEach(sel => used.add(sel.value));
|
||||
// Update options in all selects
|
||||
rows.querySelectorAll('select').forEach(sel => {
|
||||
const current = sel.value;
|
||||
sel.querySelectorAll('option').forEach(opt => {
|
||||
opt.disabled = used.has(opt.value) && opt.value !== current;
|
||||
});
|
||||
});
|
||||
addBtn.style.display = used.size >= PATTERN_TYPES.length ? 'none' : '';
|
||||
}
|
||||
|
||||
addBtn.addEventListener('click', addRow);
|
||||
document.querySelectorAll('input[name="target_environments"]').forEach(cb => {
|
||||
cb.addEventListener('change', updateName);
|
||||
});
|
||||
|
||||
// Start with one row
|
||||
addRow();
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
159
templates/pages/user_profile.html.jinja
Normal file
159
templates/pages/user_profile.html.jinja
Normal file
@@ -0,0 +1,159 @@
|
||||
{% extends "base.html.jinja" %}
|
||||
{% from "components/timestamp.html.jinja" import timeago as ts %}
|
||||
|
||||
{% block content %}
|
||||
<section class="max-w-3xl mx-auto px-4 pt-12">
|
||||
<div class="flex items-start gap-6 mb-8">
|
||||
{# Avatar placeholder #}
|
||||
<div class="w-20 h-20 rounded-full bg-gray-200 flex items-center justify-center text-3xl font-bold text-gray-500 shrink-0">
|
||||
{{ profile.username[:1] | upper }}
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ profile.username }}</h1>
|
||||
{% if profile.created_at %}
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
Member since
|
||||
{{ ts(profile.created_at) }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 pt-6">
|
||||
<h2 class="text-sm font-semibold text-gray-900 mb-4">
|
||||
{{ heatmap.total }} contribution{{ 's' if heatmap.total != 1 else '' }} in the last year
|
||||
</h2>
|
||||
|
||||
{% if heatmap.weeks | length > 0 %}
|
||||
<div class="overflow-x-auto pb-2">
|
||||
<div class="inline-block">
|
||||
{# Month labels #}
|
||||
<div class="flex text-xs text-gray-400 mb-1" style="padding-left: 28px;">
|
||||
{% set ns = namespace(last_col=-2) %}
|
||||
{% for m in heatmap.month_labels %}
|
||||
{% set gap = m.col - ns.last_col %}
|
||||
<span style="width: {{ gap * 13 }}px; display: inline-block;">{{ m.label }}</span>
|
||||
{% set ns.last_col = m.col %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-0.5">
|
||||
{# Day-of-week labels #}
|
||||
<div class="flex flex-col gap-0.5 text-xs text-gray-400 pr-1" style="width: 24px;">
|
||||
<span style="height: 11px;"></span>
|
||||
<span style="height: 11px; line-height: 11px; font-size: 9px;">Mon</span>
|
||||
<span style="height: 11px;"></span>
|
||||
<span style="height: 11px; line-height: 11px; font-size: 9px;">Wed</span>
|
||||
<span style="height: 11px;"></span>
|
||||
<span style="height: 11px; line-height: 11px; font-size: 9px;">Fri</span>
|
||||
<span style="height: 11px;"></span>
|
||||
</div>
|
||||
|
||||
{# Grid of weeks #}
|
||||
{% for week in heatmap.weeks %}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{% for day in week %}
|
||||
{% if day.in_range %}
|
||||
{% if day.count > 0 %}
|
||||
<div
|
||||
class="rounded-sm"
|
||||
style="width: 11px; height: 11px; background: oklch(55% 0.2 293 / {{ day.opacity }});"
|
||||
title="{{ day.date }}: {{ day.count }} contribution{{ 's' if day.count != 1 else '' }}"
|
||||
></div>
|
||||
{% else %}
|
||||
<div
|
||||
class="rounded-sm bg-gray-100"
|
||||
style="width: 11px; height: 11px;"
|
||||
title="{{ day.date }}: 0 contributions"
|
||||
></div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div style="width: 11px; height: 11px;"></div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Legend #}
|
||||
<div class="flex items-center justify-end gap-1 mt-2 text-xs text-gray-400">
|
||||
<span>Less</span>
|
||||
<div class="rounded-sm bg-gray-100" style="width: 11px; height: 11px;"></div>
|
||||
<div class="rounded-sm" style="width: 11px; height: 11px; background: oklch(55% 0.2 293 / 0.25);"></div>
|
||||
<div class="rounded-sm" style="width: 11px; height: 11px; background: oklch(55% 0.2 293 / 0.50);"></div>
|
||||
<div class="rounded-sm" style="width: 11px; height: 11px; background: oklch(55% 0.2 293 / 0.75);"></div>
|
||||
<div class="rounded-sm" style="width: 11px; height: 11px; background: oklch(55% 0.2 293 / 1.0);"></div>
|
||||
<span>More</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-500">No public activity yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Recent releases #}
|
||||
<div class="border-t border-gray-200 pt-6 mt-6">
|
||||
<h2 class="text-sm font-semibold text-gray-900 mb-4">Recent releases</h2>
|
||||
{% if recent_releases | length > 0 %}
|
||||
<div class="space-y-3">
|
||||
{% for r in recent_releases %}
|
||||
<a href="/orgs/{{ r.org }}/projects/{{ r.project }}" class="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-gray-50 transition-colors group">
|
||||
<div class="w-8 h-8 rounded bg-violet-100 flex items-center justify-center shrink-0">
|
||||
<svg class="w-4 h-4 text-violet-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-500">{{ r.org }}/{{ r.project }}</span>
|
||||
{% if r.version %}
|
||||
<span class="text-xs font-mono px-1.5 py-0.5 rounded bg-violet-100 text-violet-700">{{ r.version }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-400 mt-0.5">
|
||||
{% if r.branch %}
|
||||
<span class="font-mono">{{ r.branch }}</span>
|
||||
{% endif %}
|
||||
{% if r.commit_sha %}
|
||||
<span class="font-mono">{{ r.commit_sha }}</span>
|
||||
{% endif %}
|
||||
<span>{{ ts(r.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-gray-300 group-hover:text-gray-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-500">No releases yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Contributed projects #}
|
||||
<div class="border-t border-gray-200 pt-6 mt-6 pb-12">
|
||||
<h2 class="text-sm font-semibold text-gray-900 mb-4">Projects</h2>
|
||||
{% if contributed_projects | length > 0 %}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{% for p in contributed_projects %}
|
||||
<a href="/orgs/{{ p.org }}/projects/{{ p.project }}" class="flex items-center gap-3 px-3 py-3 rounded-lg border border-gray-200 hover:border-gray-300 hover:bg-gray-50 transition-colors group">
|
||||
<div class="w-8 h-8 rounded bg-gray-100 flex items-center justify-center shrink-0">
|
||||
<svg class="w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="text-sm font-medium text-gray-900 group-hover:text-violet-700">{{ p.org }}/{{ p.project }}</span>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{{ p.release_count }} release{{ 's' if p.release_count != 1 else '' }}</p>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-500">No project contributions yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user