From bdb58417527d6684d9cd3ebecf5ac40dc972d0fb Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 11 Mar 2026 00:53:13 -0500 Subject: [PATCH 1/5] feat: redesign dashboard with resource tree and live status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace static quick-actions with a live org → project → app resource tree. Eagerly load the hierarchy and compute per-app status from latest deploy/image (deploying, building, failed, ok) with color-coded dots and badges. Co-Authored-By: Claude Opus 4.6 --- cabotage/client/static/main.css | 191 +++++++++++++++++++++++ cabotage/client/templates/main/home.html | 131 +++++++++------- cabotage/server/main/views.py | 101 +++++++++++- 3 files changed, 367 insertions(+), 56 deletions(-) diff --git a/cabotage/client/static/main.css b/cabotage/client/static/main.css index 4726fde9..b2245af8 100644 --- a/cabotage/client/static/main.css +++ b/cabotage/client/static/main.css @@ -866,6 +866,197 @@ a.live-commit-sha:hover { letter-spacing: 0.05em; } +/* Dashboard Resource Tree */ +.dash-tree { + display: flex; + flex-direction: column; + gap: 0.75rem; +} +.dash-org { + border-radius: 0.75rem; + background: var(--surface-card); + border: 1px solid var(--border-color); + overflow: hidden; +} +.dash-org-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.875rem 1rem; + text-decoration: none; + transition: background 0.15s ease; +} +.dash-org-header:hover { + background: var(--hover-overlay); +} +.dash-org-icon { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 0.5rem; + background: oklch(from var(--color-primary) l c h / 0.1); + color: var(--color-primary); + flex-shrink: 0; +} +.dash-org-name { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-bright); +} +.dash-org-slug { + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 0.6875rem; + color: var(--text-faint); + margin-left: 0.5rem; +} +.dash-org-meta { + font-size: 0.6875rem; + color: var(--text-faint); + flex-shrink: 0; +} + +.dash-projects { + border-top: 1px solid var(--border-color-subtle); +} +.dash-project { + border-bottom: 1px solid var(--border-color-subtle); +} +.dash-project:last-child { + border-bottom: none; +} +.dash-project-header { + padding: 0 1rem; +} +.dash-project-link { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 0; + padding-left: 2.75rem; + text-decoration: none; + transition: background 0.1s ease; +} +.dash-project-link:hover .dash-project-name { + color: var(--text-bright); +} +.dash-project-icon { + color: var(--text-faint); + flex-shrink: 0; +} +.dash-project-name { + font-size: 0.8125rem; + font-weight: 500; + color: var(--text-muted); + transition: color 0.1s ease; +} +.dash-project-slug { + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 0.6875rem; + color: var(--text-ghost); +} +.dash-count-pill { + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 0.625rem; + font-weight: 600; + color: var(--text-faint); + background: var(--hover-overlay); + padding: 0.0625rem 0.375rem; + border-radius: 9999px; + margin-left: auto; +} + +.dash-apps { + padding: 0.125rem 1rem 0.5rem; + padding-left: 4.25rem; + display: flex; + flex-direction: column; + gap: 0; +} +.dash-app { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.3125rem 0.5rem; + margin: 0 -0.5rem; + border-radius: 0.375rem; + text-decoration: none; + transition: background 0.1s ease; +} +.dash-app:hover { + background: var(--hover-overlay); +} +.dash-app-dot { + width: 0.375rem; + height: 0.375rem; + border-radius: 50%; + background: var(--accent-text); + opacity: 0.5; + flex-shrink: 0; +} +.dash-app:hover .dash-app-dot { + opacity: 1; +} +.dash-app-name { + font-size: 0.8125rem; + color: var(--text-muted); + transition: color 0.1s ease; +} +.dash-app:hover .dash-app-name { + color: var(--accent-text); +} +.dash-app-slug { + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 0.625rem; + color: var(--text-ghost); +} +.dash-dot-deploying { + background: #3b82f6; + opacity: 1; + animation: dash-pulse 1.5s ease-in-out infinite; +} +.dash-dot-building { + background: #f59e0b; + opacity: 1; + animation: dash-pulse 1.5s ease-in-out infinite; +} +.dash-dot-error { + background: #ef4444; + opacity: 1; +} +@keyframes dash-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} +.dash-status-badge { + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 0.5625rem; + font-weight: 600; + letter-spacing: 0.02em; + padding: 0.0625rem 0.375rem; + border-radius: 9999px; + margin-left: auto; + flex-shrink: 0; + text-transform: uppercase; +} +.dash-badge-deploying { + background: rgba(59, 130, 246, 0.15); + color: #60a5fa; +} +.dash-badge-building { + background: rgba(245, 158, 11, 0.15); + color: #fbbf24; +} +.dash-badge-error { + background: rgba(239, 68, 68, 0.15); + color: #f87171; +} +.dash-empty-branch { + padding: 0.75rem 1rem 0.75rem 3.75rem; + border-top: 1px solid var(--border-color-subtle); +} + /* Process Card (legacy, see Process Grid below) */ .pod-dot { width: 0.5rem; diff --git a/cabotage/client/templates/main/home.html b/cabotage/client/templates/main/home.html index 3e7458a4..4cd0774a 100644 --- a/cabotage/client/templates/main/home.html +++ b/cabotage/client/templates/main/home.html @@ -21,7 +21,7 @@

Dashboard

- {% set org_count = current_user.organizations|length if current_user.organizations else 0 %} + {% set org_count = user_organizations|length %}
{{ stat_card(org_count, 'Organizations', 'building', 'primary') }} {{ stat_card(project_count, 'Projects', 'folder', 'secondary') }} @@ -29,62 +29,85 @@

Dashboard

{{ stat_card(deploy_count, 'Deployments', 'deploy', 'success') }}
-

Quick Actions

-
- -
-
{{ icon('building', 'w-5 h-5') }}
-
- Organizations -

Manage teams and access

-
- - - -
-
+ {% if user_organizations %} +

Your Resources

- -
-
{{ icon('folder', 'w-5 h-5') }}
-
- Projects -

View all projects

-
- - - -
-
+ + + {% else %} + {{ empty_state( + 'No organizations', + 'Create an organization to start deploying applications.', + url_for('user.organization_create'), + 'Create Organization', + 'building' + ) }} + {% endif %}
{% else %} diff --git a/cabotage/server/main/views.py b/cabotage/server/main/views.py index 7853c565..6b31acf2 100755 --- a/cabotage/server/main/views.py +++ b/cabotage/server/main/views.py @@ -1,10 +1,17 @@ from flask import render_template, Blueprint from flask_login import current_user -from sqlalchemy import func +from sqlalchemy import case, func +from sqlalchemy.orm import joinedload from cabotage.server import db +from cabotage.server.models.auth import Organization from cabotage.server.models.auth_associations import OrganizationMember -from cabotage.server.models.projects import Application, Deployment, Project +from cabotage.server.models.projects import ( + Application, + Deployment, + Image, + Project, +) main_blueprint = Blueprint( @@ -13,11 +20,88 @@ ) +def _app_statuses(user_orgs): + """Compute a status string per application_id for the dashboard. + + Returns dict[str, str] mapping app id (as str) to one of: + "deploying", "deploy-error", "building", "build-error", "ok" + + Priority: deploying > deploy-error > building > build-error > ok + Only considers the single most recent deploy/image per app. + """ + # Latest deployment per app: is it in-progress or errored? + latest_deploy = ( + db.session.query( + Deployment.application_id, + Deployment.complete, + Deployment.error, + ) + .join(Application) + .join(Project) + .filter(Project.organization_id.in_(user_orgs)) + .distinct(Deployment.application_id) + .order_by(Deployment.application_id, Deployment.created.desc()) + .subquery() + ) + + deploy_status = db.session.query( + latest_deploy.c.application_id, + case( + ( + (latest_deploy.c.complete == False) & (latest_deploy.c.error == False), # noqa: E712 + "deploying", + ), + (latest_deploy.c.error == True, "deploy-error"), # noqa: E712 + else_="ok", + ).label("status"), + ).all() + + statuses = {str(app_id): status for app_id, status in deploy_status} + + # Latest image per app: is it building or errored? + latest_image = ( + db.session.query( + Image.application_id, + Image.built, + Image.error, + ) + .join(Application) + .join(Project) + .filter(Project.organization_id.in_(user_orgs)) + .distinct(Image.application_id) + .order_by(Image.application_id, Image.created.desc()) + .subquery() + ) + + image_status = db.session.query( + latest_image.c.application_id, + case( + ( + (latest_image.c.built == False) & (latest_image.c.error == False), # noqa: E712 + "building", + ), + (latest_image.c.error == True, "build-error"), # noqa: E712 + else_="ok", + ).label("status"), + ).all() + + for app_id, status in image_status: + app_key = str(app_id) + existing = statuses.get(app_key, "ok") + # Deploy statuses take priority over image statuses + if existing == "ok" and status != "ok": + statuses[app_key] = status + + return statuses + + @main_blueprint.route("/") def home(): project_count = 0 app_count = 0 deploy_count = 0 + user_organizations = [] + app_statuses = {} if current_user.is_authenticated: user_orgs = db.session.query(OrganizationMember.organization_id).filter( OrganizationMember.user_id == current_user.id @@ -40,11 +124,24 @@ def home(): ) .scalar() ) + user_organizations = ( + Organization.query.filter(Organization.id.in_(user_orgs)) + .options( + joinedload(Organization.projects) + .joinedload(Project.project_applications) + ) + .order_by(Organization.name) + .all() + ) + if app_count: + app_statuses = _app_statuses(user_orgs) return render_template( "main/home.html", project_count=project_count, app_count=app_count, deploy_count=deploy_count, + user_organizations=user_organizations, + app_statuses=app_statuses, ) From 76a8e4ffeedc18f86831fab84c7a31cab41ea483 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 11 Mar 2026 00:58:01 -0500 Subject: [PATCH 2/5] no bad --- cabotage/server/main/views.py | 75 ----------------------------------- 1 file changed, 75 deletions(-) diff --git a/cabotage/server/main/views.py b/cabotage/server/main/views.py index 6b31acf2..d2a9b15e 100755 --- a/cabotage/server/main/views.py +++ b/cabotage/server/main/views.py @@ -20,81 +20,6 @@ ) -def _app_statuses(user_orgs): - """Compute a status string per application_id for the dashboard. - - Returns dict[str, str] mapping app id (as str) to one of: - "deploying", "deploy-error", "building", "build-error", "ok" - - Priority: deploying > deploy-error > building > build-error > ok - Only considers the single most recent deploy/image per app. - """ - # Latest deployment per app: is it in-progress or errored? - latest_deploy = ( - db.session.query( - Deployment.application_id, - Deployment.complete, - Deployment.error, - ) - .join(Application) - .join(Project) - .filter(Project.organization_id.in_(user_orgs)) - .distinct(Deployment.application_id) - .order_by(Deployment.application_id, Deployment.created.desc()) - .subquery() - ) - - deploy_status = db.session.query( - latest_deploy.c.application_id, - case( - ( - (latest_deploy.c.complete == False) & (latest_deploy.c.error == False), # noqa: E712 - "deploying", - ), - (latest_deploy.c.error == True, "deploy-error"), # noqa: E712 - else_="ok", - ).label("status"), - ).all() - - statuses = {str(app_id): status for app_id, status in deploy_status} - - # Latest image per app: is it building or errored? - latest_image = ( - db.session.query( - Image.application_id, - Image.built, - Image.error, - ) - .join(Application) - .join(Project) - .filter(Project.organization_id.in_(user_orgs)) - .distinct(Image.application_id) - .order_by(Image.application_id, Image.created.desc()) - .subquery() - ) - - image_status = db.session.query( - latest_image.c.application_id, - case( - ( - (latest_image.c.built == False) & (latest_image.c.error == False), # noqa: E712 - "building", - ), - (latest_image.c.error == True, "build-error"), # noqa: E712 - else_="ok", - ).label("status"), - ).all() - - for app_id, status in image_status: - app_key = str(app_id) - existing = statuses.get(app_key, "ok") - # Deploy statuses take priority over image statuses - if existing == "ok" and status != "ok": - statuses[app_key] = status - - return statuses - - @main_blueprint.route("/") def home(): project_count = 0 From e61dc735a98712a71f6b1830362b3c625e17b57c Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 11 Mar 2026 00:58:23 -0500 Subject: [PATCH 3/5] less bad --- cabotage/server/main/views.py | 51 ++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/cabotage/server/main/views.py b/cabotage/server/main/views.py index d2a9b15e..909d9d30 100755 --- a/cabotage/server/main/views.py +++ b/cabotage/server/main/views.py @@ -31,14 +31,13 @@ def home(): user_orgs = db.session.query(OrganizationMember.organization_id).filter( OrganizationMember.user_id == current_user.id ) + user_apps = Application.query.join(Project).filter( + Project.organization_id.in_(user_orgs) + ) project_count = Project.query.filter( Project.organization_id.in_(user_orgs) ).count() - app_count = ( - Application.query.join(Project) - .filter(Project.organization_id.in_(user_orgs)) - .count() - ) + app_count = user_apps.count() deploy_count = ( db.session.query(func.count(Deployment.id)) .join(Application) @@ -58,8 +57,46 @@ def home(): .order_by(Organization.name) .all() ) - if app_count: - app_statuses = _app_statuses(user_orgs) + # Per-app status: find apps with in-flight deploys, builds, or errors + user_app_ids = user_apps.with_entities(Application.id) + for app_id, in ( + db.session.query(Deployment.application_id) + .filter( + Deployment.application_id.in_(user_app_ids), + Deployment.complete == False, # noqa: E712 + Deployment.error == False, # noqa: E712 + ) + .distinct() + ): + app_statuses[str(app_id)] = "deploying" + for app_id, in ( + db.session.query(Deployment.application_id) + .filter( + Deployment.application_id.in_(user_app_ids), + Deployment.error == True, # noqa: E712 + ) + .distinct() + ): + app_statuses.setdefault(str(app_id), "deploy-error") + for app_id, in ( + db.session.query(Image.application_id) + .filter( + Image.application_id.in_(user_app_ids), + Image.built == False, # noqa: E712 + Image.error == False, # noqa: E712 + ) + .distinct() + ): + app_statuses.setdefault(str(app_id), "building") + for app_id, in ( + db.session.query(Image.application_id) + .filter( + Image.application_id.in_(user_app_ids), + Image.error == True, # noqa: E712 + ) + .distinct() + ): + app_statuses.setdefault(str(app_id), "build-error") return render_template( "main/home.html", project_count=project_count, From a0114b17f36260634e3550a6b705f2b7b0ca0d3a Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 11 Mar 2026 00:59:03 -0500 Subject: [PATCH 4/5] remove unused case import from main views Co-Authored-By: Claude Opus 4.6 --- cabotage/server/main/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cabotage/server/main/views.py b/cabotage/server/main/views.py index 909d9d30..5929cec7 100755 --- a/cabotage/server/main/views.py +++ b/cabotage/server/main/views.py @@ -1,6 +1,6 @@ from flask import render_template, Blueprint from flask_login import current_user -from sqlalchemy import case, func +from sqlalchemy import func from sqlalchemy.orm import joinedload from cabotage.server import db From 52083c77aa92b9f48c00f826aab9fd6b90773595 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 11 Mar 2026 01:12:05 -0500 Subject: [PATCH 5/5] fix: format views.py to pass black Co-Authored-By: Claude Opus 4.6 --- cabotage/server/main/views.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/cabotage/server/main/views.py b/cabotage/server/main/views.py index 5929cec7..d48e5828 100755 --- a/cabotage/server/main/views.py +++ b/cabotage/server/main/views.py @@ -51,15 +51,16 @@ def home(): user_organizations = ( Organization.query.filter(Organization.id.in_(user_orgs)) .options( - joinedload(Organization.projects) - .joinedload(Project.project_applications) + joinedload(Organization.projects).joinedload( + Project.project_applications + ) ) .order_by(Organization.name) .all() ) # Per-app status: find apps with in-flight deploys, builds, or errors user_app_ids = user_apps.with_entities(Application.id) - for app_id, in ( + for (app_id,) in ( db.session.query(Deployment.application_id) .filter( Deployment.application_id.in_(user_app_ids), @@ -69,7 +70,7 @@ def home(): .distinct() ): app_statuses[str(app_id)] = "deploying" - for app_id, in ( + for (app_id,) in ( db.session.query(Deployment.application_id) .filter( Deployment.application_id.in_(user_app_ids), @@ -78,7 +79,7 @@ def home(): .distinct() ): app_statuses.setdefault(str(app_id), "deploy-error") - for app_id, in ( + for (app_id,) in ( db.session.query(Image.application_id) .filter( Image.application_id.in_(user_app_ids), @@ -88,7 +89,7 @@ def home(): .distinct() ): app_statuses.setdefault(str(app_id), "building") - for app_id, in ( + for (app_id,) in ( db.session.query(Image.application_id) .filter( Image.application_id.in_(user_app_ids),