Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,12 @@ scripts/dev/dashboard-preview.sh
web-dashboard/.env.local
web-dashboard/src/styles/app.local.css

# Local full-stack dev overlay (mock GitHub + dashboard on :5173)
docker-compose.dev.local.yml
deploy/dev/
scripts/dev/up.sh
scripts/dev/mock-github.ts
infra/pulumi/package.json

# CLI global-install packing dir
.tmp-pack/
40 changes: 38 additions & 2 deletions web-dashboard/src/components/shell/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export function Breadcrumbs() {

/** Static labels for team-scoped leaf segments. */
const TEAM_LEAF_LABELS = new Map<string, string>([
["apps", "Apps"],
["apps", "Releases"],
["members", "Members"],
["invitations", "Invitations"],
["metrics", "Metrics"],
Expand All @@ -87,6 +87,42 @@ function buildCrumbs(
const segments = pathname.split("/").filter((segment) => segment.length > 0);

if (teamId !== undefined) {
const metricsIndex = segments.indexOf("metrics");
if (metricsIndex !== -1) {
const routeAppId = segments[metricsIndex + 2];
const routeDepId = segments[metricsIndex + 4];
if (segments[metricsIndex + 1] !== "apps" || routeAppId === undefined) {
return [];
}

const crumbs: Crumb[] = [
{
key: "metrics",
to: `/teams/${teamId}/metrics`,
node: "Metrics",
},
{
key: "app",
to: `/teams/${teamId}/metrics/apps/${routeAppId}`,
node: <AppName appId={routeAppId} />,
},
];

if (
segments[metricsIndex + 3] === "deployments" &&
routeDepId !== undefined
) {
crumbs.push({
key: "deployment",
node: (
<DeploymentName appId={routeAppId} deploymentId={routeDepId} />
),
});
}

return crumbs;
}

// Single-team OSS: the team root crumb is just the fixed `default-team`
// slug, so omit it; show it only in multi-team mode where it disambiguates.
const crumbs: Crumb[] = isMultiTeam
Expand All @@ -101,7 +137,7 @@ function buildCrumbs(

if (appId !== undefined) {
crumbs.push(
{ key: "apps", to: `/teams/${teamId}/apps`, node: "Apps" },
{ key: "apps", to: `/teams/${teamId}/apps`, node: "Releases" },
{
key: "app",
to: `/teams/${teamId}/apps/${appId}`,
Expand Down
4 changes: 2 additions & 2 deletions web-dashboard/src/components/shell/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Team-scoped sidebar: brand, main nav (Apps, Metrics) and the IAM "Access"
// Team-scoped sidebar: brand, main nav (Releases, Metrics) and the IAM "Access"
// group (Members, Invitations), all as data-driven react-router NavLinks
// against the route map — the DOM/class structure is ported, its hardcoded
// `.html` hrefs and global `DB` are not. Per the RBAC matrix the Access group
Expand Down Expand Up @@ -127,7 +127,7 @@ interface TeamNavItem {
const MAIN_NAV_ITEMS: readonly TeamNavItem[] = [
{
key: "apps",
label: "Apps",
label: "Releases",
segment: "apps",
icon: (
<NavIcon>
Expand Down
33 changes: 33 additions & 0 deletions web-dashboard/src/lib/appTile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
const APP_ICON_GRADIENTS = [
"linear-gradient(135deg,#0051ff,#008bf7)",
"linear-gradient(135deg,#00ceff,#008bf7)",
"linear-gradient(135deg,#fe19ff,#b517d6)",
"linear-gradient(135deg,#ff9100,#ff4d13)",
"linear-gradient(135deg,#0031ea,#0051ff)",
"linear-gradient(135deg,#008bf7,#00ceff)",
"linear-gradient(135deg,#10b981,#059669)",
"linear-gradient(135deg,#ff4d13,#ec0c43)",
] as const;

/** Stable gradient pick so an app keeps its tile color across refetches. */
export function gradientFor(appId: string): string {
let hash = 0;
for (let index = 0; index < appId.length; index += 1) {
hash = (hash * 31 + appId.charCodeAt(index)) >>> 0;
}
return APP_ICON_GRADIENTS[hash % APP_ICON_GRADIENTS.length];
}

/** Tile initials: "harbor-android" → "ha", single words → first two. */
export function initialsFor(appName: string): string {
const segments = appName
.split(/[^\p{L}\p{N}]+/u)
.filter((segment) => segment.length > 0);
const first = segments[0] ?? appName;
const second = segments[1];
const initials =
second !== undefined
? `${first.charAt(0)}${second.charAt(0)}`
: first.slice(0, 2);
return initials === "" ? "?" : initials.toLowerCase();
}
38 changes: 2 additions & 36 deletions web-dashboard/src/pages/AppsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { useTeamRole } from "../rbac/useTeamRole";
import { buildReleaseReactSnippet } from "../lib/cliSnippet";
import { formatDate } from "../model/format";
import type { App } from "../model/app";
import { gradientFor, initialsFor } from "../lib/appTile";
import type { Deployment } from "../model/deployment";
import type { FormEvent } from "react";
import { buttonVariants } from "../components/ui/Button";
Expand Down Expand Up @@ -76,7 +77,7 @@ export function AppsPage() {
<div className="mb-6 flex flex-wrap items-start gap-[18px]">
<div className="min-w-0 flex-1">
<h1 className={PAGE_TITLE}>
Apps
Releases
</h1>
<p className={PAGE_SUB}>
React Native OTA targets in this team. Each app auto-creates a
Expand Down Expand Up @@ -551,41 +552,6 @@ function chipToneFor(deploymentName: string): ChipTone {
return "blue";
}

// Identicon gradients — decorative only; the initials are aria-hidden.
const APP_ICON_GRADIENTS = [
"linear-gradient(135deg,#0051ff,#008bf7)",
"linear-gradient(135deg,#00ceff,#008bf7)",
"linear-gradient(135deg,#fe19ff,#b517d6)",
"linear-gradient(135deg,#ff9100,#ff4d13)",
"linear-gradient(135deg,#0031ea,#0051ff)",
"linear-gradient(135deg,#008bf7,#00ceff)",
"linear-gradient(135deg,#10b981,#059669)",
"linear-gradient(135deg,#ff4d13,#ec0c43)",
] as const;

/** Stable gradient pick so an app keeps its tile color across refetches. */
function gradientFor(appId: string): string {
let hash = 0;
for (let index = 0; index < appId.length; index += 1) {
hash = (hash * 31 + appId.charCodeAt(index)) >>> 0;
}
return APP_ICON_GRADIENTS[hash % APP_ICON_GRADIENTS.length];
}

/** Tile initials: "harbor-android" → "ha", single words → first two. */
function initialsFor(appName: string): string {
const segments = appName
.split(/[^\p{L}\p{N}]+/u)
.filter((segment) => segment.length > 0);
const first = segments[0] ?? appName;
const second = segments[1];
const initials =
second !== undefined
? `${first.charAt(0)}${second.charAt(0)}`
: first.slice(0, 2);
return initials === "" ? "?" : initials.toLowerCase();
}

// --- Icons (paths mirror the shared icon set) --------------------------------

function PlusIcon() {
Expand Down
6 changes: 3 additions & 3 deletions web-dashboard/src/pages/TeamOverviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ function TeamOverview({ teamId }: { teamId: string }) {
icon={<AppsIcon />}
iconBackground="var(--color-blue-tint)"
iconColor="var(--color-blue)"
title="Apps"
title="Releases"
subtitle="Manage apps & deployments"
/>
{!membersHidden ? (
Expand Down Expand Up @@ -157,13 +157,13 @@ function AppsTile({ teamId }: { teamId: string }) {
<CountTile
accent="var(--color-blue)"
icon={<AppsIcon />}
label="Apps"
label="Releases"
count={appsQuery.data?.length}
isPending={appsQuery.isPending}
isError={appsQuery.isError}
onRetry={() => void appsQuery.refetch()}
to={`/teams/${teamId}/apps`}
linkText="View apps"
linkText="View releases"
/>
);
}
Expand Down
Loading