Compare commits
9 Commits
feat/wire-
...
fix/kaniko
| Author | SHA1 | Date | |
|---|---|---|---|
| e0bc522ecf | |||
| 173b429c62 | |||
| 7d505e75f8 | |||
| cd1c52c506 | |||
| a00f1e1fd7 | |||
| 9305cacd4a | |||
| 0d5aa5c3ae | |||
| eb34eb8104 | |||
| 5165a30fad |
27
.woodpecker/base-image.yml
Normal file
27
.woodpecker/base-image.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
when:
|
||||
- event: manual
|
||||
- event: cron
|
||||
cron: weekly-base-image
|
||||
|
||||
variables:
|
||||
- &kaniko_setup |
|
||||
mkdir -p /kaniko/.docker
|
||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
|
||||
|
||||
steps:
|
||||
build-base:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
environment:
|
||||
GITEA_USER:
|
||||
from_secret: gitea_username
|
||||
GITEA_TOKEN:
|
||||
from_secret: gitea_token
|
||||
commands:
|
||||
- *kaniko_setup
|
||||
- /kaniko/executor
|
||||
--context .
|
||||
--dockerfile docker/base.Dockerfile
|
||||
--destination git.mosaicstack.dev/mosaic/node-base:24-slim
|
||||
--destination git.mosaicstack.dev/mosaic/node-base:latest
|
||||
--cache=true
|
||||
--cache-repo git.mosaicstack.dev/mosaic/node-base/cache
|
||||
@@ -32,6 +32,7 @@ variables:
|
||||
- &node_image "node:24-alpine"
|
||||
- &install_deps |
|
||||
corepack enable
|
||||
pnpm config set store-dir /root/.local/share/pnpm/store
|
||||
pnpm install --frozen-lockfile
|
||||
- &use_deps |
|
||||
corepack enable
|
||||
@@ -168,7 +169,7 @@ steps:
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:latest"
|
||||
fi
|
||||
/kaniko/executor --context . --dockerfile apps/api/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
||||
/kaniko/executor --context . --dockerfile apps/api/Dockerfile --snapshot-mode=redo --cache=true --cache-repo git.mosaicstack.dev/mosaic/stack-api/cache $DESTINATIONS
|
||||
when:
|
||||
- branch: [main]
|
||||
event: [push, manual, tag]
|
||||
@@ -193,7 +194,7 @@ steps:
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:latest"
|
||||
fi
|
||||
/kaniko/executor --context . --dockerfile apps/orchestrator/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
||||
/kaniko/executor --context . --dockerfile apps/orchestrator/Dockerfile --snapshot-mode=redo --cache=true --cache-repo git.mosaicstack.dev/mosaic/stack-orchestrator/cache $DESTINATIONS
|
||||
when:
|
||||
- branch: [main]
|
||||
event: [push, manual, tag]
|
||||
@@ -218,7 +219,7 @@ steps:
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:latest"
|
||||
fi
|
||||
/kaniko/executor --context . --dockerfile apps/web/Dockerfile --snapshot-mode=redo --build-arg NEXT_PUBLIC_API_URL=https://api.mosaicstack.dev $DESTINATIONS
|
||||
/kaniko/executor --context . --dockerfile apps/web/Dockerfile --snapshot-mode=redo --cache=true --cache-repo git.mosaicstack.dev/mosaic/stack-web/cache --build-arg NEXT_PUBLIC_API_URL=https://api.mosaicstack.dev $DESTINATIONS
|
||||
when:
|
||||
- branch: [main]
|
||||
event: [push, manual, tag]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Base image for all stages
|
||||
# Uses Debian slim (glibc) instead of Alpine (musl) because native Node.js addons
|
||||
# (matrix-sdk-crypto-nodejs, Prisma engines) require glibc-compatible binaries.
|
||||
FROM node:24-slim AS base
|
||||
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS base
|
||||
|
||||
# Install pnpm globally
|
||||
RUN corepack enable && corepack prepare pnpm@10.27.0 --activate
|
||||
@@ -19,9 +19,9 @@ COPY turbo.json ./
|
||||
FROM base AS deps
|
||||
|
||||
# Install build tools for native addons (node-pty requires node-gyp compilation)
|
||||
# and OpenSSL for Prisma engine detection
|
||||
# Note: openssl and ca-certificates pre-installed in base image
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 make g++ openssl \
|
||||
python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy all package.json files for workspace resolution
|
||||
@@ -61,19 +61,14 @@ RUN pnpm turbo build --filter=@mosaic/api --force
|
||||
# ======================
|
||||
# Production stage
|
||||
# ======================
|
||||
FROM node:24-slim AS production
|
||||
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS production
|
||||
|
||||
# Install dumb-init for proper signal handling (static binary from GitHub,
|
||||
# avoids apt-get which fails under Kaniko with bookworm GPG signature errors)
|
||||
ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 /usr/local/bin/dumb-init
|
||||
# dumb-init, openssl, ca-certificates pre-installed in base image
|
||||
|
||||
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
|
||||
# - openssl: Prisma engine detection requires libssl
|
||||
# - No build tools needed here — native addons are compiled in the deps stage
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
|
||||
&& chmod 755 /usr/local/bin/dumb-init \
|
||||
# - Remove npm/npx to reduce image size (not used in production)
|
||||
# - Create non-root user
|
||||
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
|
||||
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -384,10 +384,18 @@ describe("ActivityLoggingInterceptor", () => {
|
||||
const context = createMockExecutionContext("POST", {}, body, user);
|
||||
const next = createMockCallHandler(result);
|
||||
|
||||
mockActivityService.logActivity.mockResolvedValue({
|
||||
id: "activity-123",
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
// Should not call logActivity when workspaceId is missing
|
||||
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
|
||||
// workspaceId is now optional, so logActivity should be called without it
|
||||
expect(mockActivityService.logActivity).toHaveBeenCalled();
|
||||
const callArgs = mockActivityService.logActivity.mock.calls[0][0];
|
||||
expect(callArgs.userId).toBe("user-123");
|
||||
expect(callArgs.entityId).toBe("task-123");
|
||||
expect(callArgs.workspaceId).toBeUndefined();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
@@ -412,10 +420,18 @@ describe("ActivityLoggingInterceptor", () => {
|
||||
const context = createMockExecutionContext("POST", {}, body, user);
|
||||
const next = createMockCallHandler(result);
|
||||
|
||||
mockActivityService.logActivity.mockResolvedValue({
|
||||
id: "activity-123",
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
interceptor.intercept(context, next).subscribe(() => {
|
||||
// Should not call logActivity when workspaceId is missing
|
||||
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
|
||||
// workspaceId is now optional, so logActivity should be called without it
|
||||
expect(mockActivityService.logActivity).toHaveBeenCalled();
|
||||
const callArgs = mockActivityService.logActivity.mock.calls[0][0];
|
||||
expect(callArgs.userId).toBe("user-123");
|
||||
expect(callArgs.entityId).toBe("task-123");
|
||||
expect(callArgs.workspaceId).toBeUndefined();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Base image for all stages
|
||||
# Uses Debian slim (glibc) instead of Alpine (musl) for native addon compatibility.
|
||||
FROM node:24-slim AS base
|
||||
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS base
|
||||
|
||||
# Install pnpm globally
|
||||
RUN corepack enable && corepack prepare pnpm@10.27.0 --activate
|
||||
@@ -54,7 +54,7 @@ RUN find ./apps/orchestrator/dist \( -name '*.spec.js' -o -name '*.spec.js.map'
|
||||
# ======================
|
||||
# Production stage
|
||||
# ======================
|
||||
FROM node:24-slim AS production
|
||||
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS production
|
||||
|
||||
# Add metadata labels
|
||||
LABEL maintainer="mosaic-team@mosaicstack.dev"
|
||||
@@ -65,13 +65,12 @@ LABEL org.opencontainers.image.vendor="Mosaic Stack"
|
||||
LABEL org.opencontainers.image.title="Mosaic Orchestrator"
|
||||
LABEL org.opencontainers.image.description="Agent orchestration service for Mosaic Stack"
|
||||
|
||||
# Install dumb-init for proper signal handling (static binary from GitHub,
|
||||
# avoids apt-get which fails under Kaniko with bookworm GPG signature errors)
|
||||
ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 /usr/local/bin/dumb-init
|
||||
# dumb-init, ca-certificates pre-installed in base image
|
||||
|
||||
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
|
||||
# - Remove npm/npx to reduce image size (not used in production)
|
||||
# - Create non-root user
|
||||
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
|
||||
&& chmod 755 /usr/local/bin/dumb-init \
|
||||
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Base image for all stages
|
||||
# Uses Debian slim (glibc) for consistency with API/orchestrator and to prevent
|
||||
# future native addon compatibility issues with Alpine's musl libc.
|
||||
FROM node:24-slim AS base
|
||||
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS base
|
||||
|
||||
# Install pnpm globally
|
||||
RUN corepack enable && corepack prepare pnpm@10.27.0 --activate
|
||||
@@ -87,15 +87,14 @@ RUN mkdir -p ./apps/web/public
|
||||
# ======================
|
||||
# Production stage
|
||||
# ======================
|
||||
FROM node:24-slim AS production
|
||||
FROM git.mosaicstack.dev/mosaic/node-base:24-slim AS production
|
||||
|
||||
# Install dumb-init for proper signal handling (static binary from GitHub,
|
||||
# avoids apt-get which fails under Kaniko with bookworm GPG signature errors)
|
||||
ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 /usr/local/bin/dumb-init
|
||||
# dumb-init, ca-certificates pre-installed in base image
|
||||
|
||||
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
|
||||
# - Remove npm/npx to reduce image size (not used in production)
|
||||
# - Create non-root user
|
||||
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
|
||||
&& chmod 755 /usr/local/bin/dumb-init \
|
||||
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nextjs
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -184,10 +184,11 @@ function TaskCard({ task, provided, snapshot, columnAccent }: TaskCardProps): Re
|
||||
interface KanbanColumnProps {
|
||||
config: ColumnConfig;
|
||||
tasks: Task[];
|
||||
onAddTask: (status: TaskStatus, title: string) => Promise<void>;
|
||||
onAddTask: (status: TaskStatus, title: string, projectId?: string) => Promise<void>;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
function KanbanColumn({ config, tasks, onAddTask }: KanbanColumnProps): ReactElement {
|
||||
function KanbanColumn({ config, tasks, onAddTask, projectId }: KanbanColumnProps): ReactElement {
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -208,7 +209,7 @@ function KanbanColumn({ config, tasks, onAddTask }: KanbanColumnProps): ReactEle
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onAddTask(config.status, inputValue.trim());
|
||||
await onAddTask(config.status, inputValue.trim(), projectId);
|
||||
setInputValue("");
|
||||
setShowAddForm(false);
|
||||
} catch (err) {
|
||||
@@ -362,6 +363,45 @@ function KanbanColumn({ config, tasks, onAddTask }: KanbanColumnProps): ReactEle
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !inputValue.trim()}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
borderRadius: "var(--r)",
|
||||
border: "1px solid var(--primary)",
|
||||
background: "var(--primary)",
|
||||
color: "#fff",
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 500,
|
||||
cursor: isSubmitting || !inputValue.trim() ? "not-allowed" : "pointer",
|
||||
opacity: isSubmitting || !inputValue.trim() ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
✓ Add
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowAddForm(false);
|
||||
setInputValue("");
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
borderRadius: "var(--r)",
|
||||
border: "1px solid var(--border)",
|
||||
background: "transparent",
|
||||
color: "var(--muted)",
|
||||
fontSize: "0.8rem",
|
||||
cursor: isSubmitting ? "not-allowed" : "pointer",
|
||||
opacity: isSubmitting ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: "0.75rem", color: "var(--muted)" }}>
|
||||
Press{" "}
|
||||
<kbd
|
||||
@@ -745,10 +785,17 @@ export default function KanbanPage(): ReactElement {
|
||||
/* --- add task handler --- */
|
||||
|
||||
const handleAddTask = useCallback(
|
||||
async (status: TaskStatus, title: string) => {
|
||||
async (status: TaskStatus, title: string, projectId?: string) => {
|
||||
try {
|
||||
const wsId = workspaceId ?? undefined;
|
||||
const newTask = await createTask({ title, status }, wsId);
|
||||
const taskData: { title: string; status: TaskStatus; projectId?: string } = {
|
||||
title,
|
||||
status,
|
||||
};
|
||||
if (projectId) {
|
||||
taskData.projectId = projectId;
|
||||
}
|
||||
const newTask = await createTask(taskData, wsId);
|
||||
// Optimistically add to local state
|
||||
setTasks((prev) => [...prev, newTask]);
|
||||
} catch (err: unknown) {
|
||||
@@ -866,23 +913,8 @@ export default function KanbanPage(): ReactElement {
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
/* Empty state */
|
||||
<div
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
padding: 48,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<p style={{ color: "var(--muted)", margin: 0, fontSize: "0.9rem" }}>
|
||||
No tasks yet. Create some tasks to see them here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Board */
|
||||
/* Board (always render columns to allow adding first task) */
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<div
|
||||
style={{
|
||||
@@ -899,6 +931,7 @@ export default function KanbanPage(): ReactElement {
|
||||
config={col}
|
||||
tasks={grouped[col.status]}
|
||||
onAddTask={handleAddTask}
|
||||
projectId={filterProject}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import { UsageWidget } from "@/components/ui/UsageWidget";
|
||||
import { useSidebar } from "./SidebarContext";
|
||||
|
||||
/**
|
||||
@@ -350,6 +351,9 @@ export function AppHeader(): React.JSX.Element {
|
||||
{/* Theme Toggle */}
|
||||
<ThemeToggle />
|
||||
|
||||
{/* Usage Widget */}
|
||||
<UsageWidget />
|
||||
|
||||
{/* User Avatar + Dropdown */}
|
||||
<div ref={dropdownRef} style={{ position: "relative", flexShrink: 0 }}>
|
||||
<button
|
||||
|
||||
337
apps/web/src/components/ui/UsageWidget.tsx
Normal file
337
apps/web/src/components/ui/UsageWidget.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { fetchUsageSummary, type UsageSummary } from "@/lib/api/telemetry";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────
|
||||
|
||||
interface UsageTier {
|
||||
name: string;
|
||||
tokens: number;
|
||||
limit: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function getUsageColor(percentage: number): string {
|
||||
if (percentage < 60) return "var(--success)";
|
||||
if (percentage < 80) return "var(--warn)";
|
||||
return "var(--danger)";
|
||||
}
|
||||
|
||||
function formatTokens(value: number): string {
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
|
||||
return value.toFixed(0);
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────
|
||||
|
||||
export function UsageWidget(): React.JSX.Element {
|
||||
const [summary, setSummary] = useState<UsageSummary | null>(null);
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const tiers: UsageTier[] = summary
|
||||
? [
|
||||
{
|
||||
name: "Session",
|
||||
tokens: summary.totalTokens,
|
||||
limit: 100_000,
|
||||
percentage: (summary.totalTokens / 100_000) * 100,
|
||||
},
|
||||
{
|
||||
name: "Daily",
|
||||
tokens: summary.totalTokens,
|
||||
limit: 500_000,
|
||||
percentage: (summary.totalTokens / 500_000) * 100,
|
||||
},
|
||||
{
|
||||
name: "Monthly",
|
||||
tokens: summary.totalTokens,
|
||||
limit: 2_000_000,
|
||||
percentage: (summary.totalTokens / 2_000_000) * 100,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const currentTier = tiers[0];
|
||||
const usageColor = currentTier ? getUsageColor(currentTier.percentage) : "var(--muted)";
|
||||
|
||||
const loadSummary = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchUsageSummary("30d");
|
||||
setSummary(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load usage summary:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSummary();
|
||||
}, [loadSummary]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent): void {
|
||||
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
|
||||
setPopoverOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!popoverOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return (): void => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [popoverOpen]);
|
||||
|
||||
const pct = currentTier ? Math.min(currentTier.percentage, 100) : 0;
|
||||
|
||||
return (
|
||||
<div ref={popoverRef} style={{ position: "relative" }}>
|
||||
<button
|
||||
onClick={(): void => {
|
||||
setPopoverOpen((prev) => !prev);
|
||||
}}
|
||||
aria-label="Usage widget"
|
||||
aria-expanded={popoverOpen}
|
||||
aria-haspopup="true"
|
||||
className="hidden lg:flex items-center"
|
||||
style={{
|
||||
gap: 6,
|
||||
padding: "5px 10px",
|
||||
borderRadius: 6,
|
||||
background: "var(--surface)",
|
||||
border: `1px solid ${popoverOpen ? usageColor : "var(--border)"}`,
|
||||
fontSize: "0.75rem",
|
||||
fontFamily: "var(--mono)",
|
||||
color: "var(--text-2)",
|
||||
cursor: "pointer",
|
||||
transition: "border-color 0.15s, color 0.15s",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e): void => {
|
||||
(e.currentTarget as HTMLButtonElement).style.borderColor = usageColor;
|
||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
||||
}}
|
||||
onMouseLeave={(e): void => {
|
||||
if (!popoverOpen) {
|
||||
(e.currentTarget as HTMLButtonElement).style.borderColor = "var(--border)";
|
||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--text-2)";
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ color: usageColor, flexShrink: 0 }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M9 1L3 9h5l-1 6 6-8H8l1-6z" />
|
||||
</svg>
|
||||
<span style={{ fontWeight: 500, color: "var(--text-2)" }}>
|
||||
{isLoading ? "..." : summary ? formatTokens(summary.totalTokens) : "0"}
|
||||
</span>
|
||||
{!isLoading && currentTier && (
|
||||
<div
|
||||
style={{
|
||||
width: 24,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
background: "var(--bg-mid)",
|
||||
overflow: "hidden",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${String(pct)}%`,
|
||||
height: "100%",
|
||||
background: usageColor,
|
||||
borderRadius: 2,
|
||||
transition: "width 0.3s ease-out",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && currentTier && (
|
||||
<span style={{ fontWeight: 600, color: usageColor, minWidth: 32, textAlign: "right" }}>
|
||||
{Math.round(currentTier.percentage)}%
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{popoverOpen && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Usage details"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "calc(100% + 8px)",
|
||||
right: 0,
|
||||
width: 280,
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.3)",
|
||||
zIndex: 200,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.83rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--text)",
|
||||
marginBottom: 12,
|
||||
paddingBottom: 8,
|
||||
borderBottom: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
Token Usage
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "20px 0",
|
||||
color: "var(--muted)",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
Loading usage data…
|
||||
</div>
|
||||
) : summary ? (
|
||||
<>
|
||||
<div style={{ marginBottom: 12, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "space-between", fontSize: "0.75rem" }}
|
||||
>
|
||||
<span style={{ color: "var(--muted)" }}>Total Tokens</span>
|
||||
<span style={{ color: "var(--text)", fontFamily: "var(--mono)" }}>
|
||||
{formatTokens(summary.totalTokens)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "space-between", fontSize: "0.75rem" }}
|
||||
>
|
||||
<span style={{ color: "var(--muted)" }}>Estimated Cost</span>
|
||||
<span style={{ color: "var(--text)", fontFamily: "var(--mono)" }}>
|
||||
${summary.totalCost.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "space-between", fontSize: "0.75rem" }}
|
||||
>
|
||||
<span style={{ color: "var(--muted)" }}>Tasks</span>
|
||||
<span style={{ color: "var(--text)", fontFamily: "var(--mono)" }}>
|
||||
{summary.taskCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{tiers.map((tier) => {
|
||||
const tierPct = Math.min(tier.percentage, 100);
|
||||
return (
|
||||
<div key={tier.name}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: "0.75rem",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--text-2)" }}>{tier.name}</span>
|
||||
<span
|
||||
style={{
|
||||
color: getUsageColor(tier.percentage),
|
||||
fontFamily: "var(--mono)",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{formatTokens(tier.tokens)} / {formatTokens(tier.limit)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
background: "var(--bg-mid)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${String(tierPct)}%`,
|
||||
height: "100%",
|
||||
background: getUsageColor(tier.percentage),
|
||||
borderRadius: 3,
|
||||
transition: "width 0.3s ease-out",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/usage"
|
||||
onClick={(): void => {
|
||||
setPopoverOpen(false);
|
||||
}}
|
||||
style={{
|
||||
display: "block",
|
||||
marginTop: 12,
|
||||
paddingTop: 8,
|
||||
borderTop: "1px solid var(--border)",
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--primary)",
|
||||
textDecoration: "none",
|
||||
textAlign: "center",
|
||||
}}
|
||||
onMouseEnter={(e): void => {
|
||||
(e.currentTarget as HTMLAnchorElement).style.textDecoration = "underline";
|
||||
}}
|
||||
onMouseLeave={(e): void => {
|
||||
(e.currentTarget as HTMLAnchorElement).style.textDecoration = "none";
|
||||
}}
|
||||
>
|
||||
View detailed usage →
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "20px 0",
|
||||
color: "var(--muted)",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
No usage data available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
docker/base.Dockerfile
Normal file
16
docker/base.Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM node:24-slim AS base
|
||||
|
||||
# Pre-bake OS updates and common packages shared across all apps.
|
||||
# Rebuild this image weekly or when base packages change.
|
||||
# Push to: git.mosaicstack.dev/mosaic/node-base:24-slim
|
||||
RUN apt-get update && apt-get upgrade -y --no-install-recommends \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
openssl \
|
||||
ca-certificates \
|
||||
curl \
|
||||
dumb-init \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Enable corepack for pnpm
|
||||
RUN corepack enable
|
||||
Reference in New Issue
Block a user