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

-
- - - -
-
+
+ {% for org in user_organizations %} + + {% endfor %} +
+ + {% 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..d48e5828 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.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( @@ -18,18 +25,19 @@ 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 ) + 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) @@ -40,11 +48,63 @@ 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() + ) + # 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, app_count=app_count, deploy_count=deploy_count, + user_organizations=user_organizations, + app_statuses=app_statuses, )