feat: add swimlanes

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-03-07 22:53:48 +01:00
parent 9fe1630986
commit 45353089c2
51 changed files with 3845 additions and 147 deletions

View File

@@ -8,52 +8,79 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body class="bg-white text-gray-900 antialiased">
<nav class="border-b border-gray-200">
<div class="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between">
{% if user is defined and user %}
{# Authenticated nav #}
<div class="flex items-center gap-6">
<a href="/dashboard" class="text-xl font-bold tracking-tight">forage</a>
{% if user is defined and user %}
{# ── Authenticated nav ─────────────────────────────────────── #}
<nav class="border-b border-gray-200 pt-3">
{# Top bar: breadcrumb + user actions #}
<div class="max-w-6xl mx-auto px-4 pb-3 flex items-center justify-between">
<div class="flex items-center gap-2 text-sm min-w-0">
<a href="/dashboard" class="text-lg font-bold tracking-tight shrink-0">forage</a>
{% if current_org is defined and current_org %}
<span class="text-sm text-gray-400">/</span>
<span class="text-sm font-medium">{{ current_org }}</span>
{% endif %}
</div>
<div class="flex items-center gap-6">
{% if current_org is defined and current_org %}
<a href="/orgs/{{ current_org }}/projects" class="text-sm text-gray-600 hover:text-gray-900">Projects</a>
<a href="/orgs/{{ current_org }}/settings/members" class="text-sm text-gray-600 hover:text-gray-900">Members</a>
<a href="/orgs/{{ current_org }}/usage" class="text-sm text-gray-600 hover:text-gray-900">Usage</a>
{% endif %}
<span class="text-gray-300">/</span>
{% if orgs is defined and orgs | length > 1 %}
<details class="relative">
<summary class="text-sm text-gray-600 hover:text-gray-900 cursor-pointer list-none">
Switch org
<summary class="font-medium text-gray-900 hover:text-black cursor-pointer list-none">
{{ current_org }}
<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 right-0 mt-2 w-48 bg-white border border-gray-200 rounded-md shadow-lg z-10">
<div class="absolute left-0 mt-1 w-48 bg-white border border-gray-200 rounded-md shadow-lg z-20 py-1">
{% for org in orgs %}
<a href="/orgs/{{ org.name }}/projects" class="block px-4 py-2 text-sm hover:bg-gray-50">{{ org.name }}</a>
<a href="/orgs/{{ org.name }}/projects" class="block px-3 py-1.5 text-sm hover:bg-gray-50{% if org.name == current_org %} font-medium bg-gray-50{% endif %}">{{ org.name }}</a>
{% endfor %}
</div>
</details>
{% else %}
<a href="/orgs/{{ current_org }}/projects" class="font-medium text-gray-900 hover:text-black">{{ current_org }}</a>
{% endif %}
<a href="/settings/tokens" class="text-sm text-gray-600 hover:text-gray-900">Tokens</a>
{% if 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">
<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 }}">
<button type="submit" class="text-sm text-gray-600 hover:text-gray-900">Sign out</button>
<button type="submit" class="text-sm text-gray-500 hover:text-gray-900">Sign out</button>
</form>
</div>
{% else %}
{# Marketing nav #}
</div>
{# 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 #}
<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) #}
<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>
{% endif %}
</div>
</div>
</nav>
{% else %}
{# ── Marketing nav ─────────────────────────────────────────── #}
<nav class="border-b border-gray-200">
<div class="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between">
<a href="/" class="text-xl font-bold tracking-tight">forage</a>
<div class="flex items-center gap-6">
<a href="/pricing" class="text-sm text-gray-600 hover:text-gray-900">Pricing</a>
<a href="/components" class="text-sm text-gray-600 hover:text-gray-900">Components</a>
<a href="/login" class="text-sm font-medium px-4 py-2 bg-gray-900 text-white rounded-md hover:bg-gray-800">Sign in</a>
</div>
{% endif %}
</div>
</nav>
{% endif %}
<main>
{% block content %}{% endblock %}

View File

@@ -0,0 +1,87 @@
{% extends "base.html.jinja" %}
{% block content %}
<section class="max-w-2xl mx-auto px-4 py-12">
<div class="flex items-center justify-between mb-8">
<h1 class="text-2xl font-bold">Account Settings</h1>
<a href="/dashboard" class="text-sm text-gray-500 hover:text-gray-700">&larr; Dashboard</a>
</div>
{% if error %}
<div class="mb-6 p-3 bg-red-50 border border-red-200 rounded-md text-sm text-red-700">
{{ error }}
</div>
{% endif %}
{# Username #}
<div class="mb-12">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Username</h2>
<form method="POST" action="/settings/account/username" class="flex gap-2">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<input type="text" name="username" value="{{ user.username }}"
class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900">
<button type="submit" class="px-4 py-2 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-800 whitespace-nowrap">
Update
</button>
</form>
</div>
{# Emails #}
<div class="mb-12">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Email addresses</h2>
<div class="space-y-2 mb-4">
{% for email in user.emails %}
<div class="flex items-center justify-between px-4 py-3 border border-gray-200 rounded-lg">
<div class="flex items-center gap-2">
<span class="text-sm text-gray-900">{{ email.email }}</span>
{% if email.verified %}
<span class="text-xs text-green-700 bg-green-100 px-1.5 py-0.5 rounded">verified</span>
{% else %}
<span class="text-xs text-yellow-700 bg-yellow-100 px-1.5 py-0.5 rounded">unverified</span>
{% endif %}
</div>
<form method="POST" action="/settings/account/emails/remove">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<input type="hidden" name="email" value="{{ email.email }}">
<button type="submit" class="text-xs text-red-600 hover:text-red-800">Remove</button>
</form>
</div>
{% endfor %}
</div>
<form method="POST" action="/settings/account/emails" class="flex gap-2">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<input type="email" name="email" placeholder="new@example.com"
class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900">
<button type="submit" class="px-4 py-2 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-800 whitespace-nowrap">
Add email
</button>
</form>
</div>
{# Change password #}
<div class="mb-12">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Change password</h2>
<form method="POST" action="/settings/account/password" class="space-y-3">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<div>
<label class="block text-sm text-gray-700 mb-1">Current password</label>
<input type="password" name="current_password" required
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900">
</div>
<div>
<label class="block text-sm text-gray-700 mb-1">New password</label>
<input type="password" name="new_password" required
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900">
</div>
<div>
<label class="block text-sm text-gray-700 mb-1">Confirm new password</label>
<input type="password" name="new_password_confirm" required
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900">
</div>
<button type="submit" class="px-4 py-2 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-800">
Change password
</button>
</form>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,124 @@
{% extends "base.html.jinja" %}
{% block content %}
<section class="max-w-4xl mx-auto px-4 pt-12">
<div class="mb-8">
<a href="/orgs/{{ org_name }}/projects/{{ project_name }}" class="text-sm text-gray-500 hover:text-gray-700">&larr; {{ project_name }}</a>
<div class="flex items-center gap-3 mt-1">
<h1 class="text-2xl font-bold">{{ artifact.title }}</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>
</div>
{% if artifact.description %}
<div class="mb-6">
<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>
{% 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>
{% 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>
{% 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>
{% 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>
{% endif %}
</dl>
</div>
{% endif %}
</div>
<!-- Links -->
{% if artifact.web or artifact.pr %}
<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>
{% 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>
{% endif %}
</div>
</div>
{% endif %}
<!-- Destinations -->
{% if artifact.destinations %}
<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 %}
</div>
</div>
{% endif %}
<div class="text-sm text-gray-500">
<p>Created {{ artifact.created_at }}</p>
</div>
</section>
{% endblock %}

View File

@@ -1,66 +1,56 @@
{% extends "base.html.jinja" %}
{% block content %}
<section class="max-w-6xl mx-auto px-4 pt-8">
<div class="flex gap-8">
{# Org sidebar #}
<aside class="w-64 shrink-0">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Organisations</h2>
<ul class="space-y-1">
{% for org in orgs %}
<li>
<a href="/orgs/{{ org.name }}/projects" class="block px-3 py-2 text-sm rounded-md hover:bg-gray-100">
{{ org.name }}
<span class="text-xs text-gray-400 ml-1">{{ org.role }}</span>
</a>
</li>
{% endfor %}
</ul>
<div class="mt-4 pt-4 border-t border-gray-200">
<form method="POST" action="/orgs" class="space-y-2">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<input type="text" name="name" placeholder="new-org-name"
class="w-full px-3 py-1.5 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900">
<button type="submit" class="w-full px-3 py-1.5 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-800">
Create organisation
</button>
</form>
</div>
</aside>
{# Main content #}
<div class="flex-1 min-w-0">
<h1 class="text-2xl font-bold mb-6">Recent activity</h1>
{% if recent_activity %}
<div class="space-y-3">
{% for item in recent_activity %}
<a href="/orgs/{{ item.org_name }}/projects/{{ item.project_name }}" class="block p-4 border border-gray-200 rounded-lg hover:border-gray-300 transition-colors">
<div class="flex items-center justify-between">
<div class="min-w-0">
<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>
{% if item.description %}
<p class="text-sm text-gray-600 mt-1 truncate">{{ item.description }}</p>
{% endif %}
</div>
<div class="text-right text-sm text-gray-400 shrink-0 ml-4">
<p class="font-mono text-xs">{{ item.slug }}</p>
<p>{{ item.created_at }}</p>
</div>
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="p-8 border border-gray-200 rounded-lg text-center">
<p class="text-gray-500">No recent activity</p>
<p class="text-sm text-gray-400 mt-2">Deploy your first release with <code class="bg-gray-100 px-1 rounded">forest release create</code></p>
</div>
{% endif %}
<section class="max-w-2xl mx-auto px-4 py-12">
{# Organisations #}
<div class="mb-12">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Your organisations</h2>
<div class="space-y-2">
{% for org in orgs %}
<a href="/orgs/{{ org.name }}/projects" class="flex items-center justify-between px-4 py-3 border border-gray-200 rounded-lg hover:border-gray-300 hover:bg-gray-50 transition-colors">
<span class="font-medium text-gray-900">{{ org.name }}</span>
<span class="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">{{ org.role }}</span>
</a>
{% endfor %}
</div>
<form method="POST" action="/orgs" class="flex gap-2 mt-4">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<input type="text" name="name" placeholder="new-org-name"
class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-gray-900">
<button type="submit" class="px-4 py-2 text-sm bg-gray-900 text-white rounded-md hover:bg-gray-800 whitespace-nowrap">
Create organisation
</button>
</form>
</div>
{# Recent activity #}
<div>
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Recent activity</h2>
{% 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">
<div class="flex items-center justify-between">
<div class="min-w-0">
<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>
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="px-4 py-10 border border-gray-200 rounded-lg text-center">
<p class="text-gray-500">No recent activity</p>
<p class="text-sm text-gray-400 mt-2">Deploy your first release with <code class="bg-gray-100 px-1.5 py-0.5 rounded">forest release create</code></p>
</div>
{% endif %}
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,65 @@
{% extends "base.html.jinja" %}
{% 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>
</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>
{% endif %}
</div>
</div>
{% if dest.created_at %}
<span class="text-xs text-gray-400 shrink-0" title="{{ dest.created_at | datetime }}">{{ dest.created_at | timeago }}</span>
{% endif %}
</div>
{% endfor %}
</div>
{% 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>
</div>
{% endif %}
</section>
{% endblock %}

View File

@@ -18,7 +18,7 @@
<div class="flex items-center justify-between">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="font-medium">{{ artifact.title }}</p>
<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 %}

View File

@@ -0,0 +1,152 @@
{% 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">
<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">&middot;</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 %}
</section>
<script src="/static/js/swim-lanes.js"></script>
{% endblock %}