feat: optimize web performance and add production deploy workflow
This commit is contained in:
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:18-alpine
|
||||
env:
|
||||
POSTGRES_USER: pulsescore
|
||||
POSTGRES_PASSWORD: pulsescore
|
||||
|
||||
69
.github/workflows/deploy-prod.yml
vendored
Normal file
69
.github/workflows/deploy-prod.yml
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
name: Deploy Production
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Git ref to deploy (branch, tag, or commit SHA)
|
||||
required: true
|
||||
default: main
|
||||
run_migrations:
|
||||
description: Request migrations during deploy (currently a no-op)
|
||||
required: true
|
||||
type: choice
|
||||
default: "false"
|
||||
options:
|
||||
- "false"
|
||||
- "true"
|
||||
|
||||
concurrency:
|
||||
group: production-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to VPS
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- name: Validate required secrets
|
||||
env:
|
||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||
VPS_USER: ${{ secrets.VPS_USER }}
|
||||
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
|
||||
VPS_APP_DIR: ${{ secrets.VPS_APP_DIR }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for key in VPS_HOST VPS_USER VPS_SSH_KEY VPS_APP_DIR; do
|
||||
if [ -z "${!key}" ]; then
|
||||
echo "::error::Missing required repository secret: $key"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Deploy over SSH
|
||||
uses: appleboy/ssh-action@v1.2.2
|
||||
with:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
username: ${{ secrets.VPS_USER }}
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
port: ${{ secrets.VPS_PORT != '' && secrets.VPS_PORT || 22 }}
|
||||
script_stop: true
|
||||
command_timeout: 45m
|
||||
script: |
|
||||
set -eu
|
||||
|
||||
cd "${{ secrets.VPS_APP_DIR }}"
|
||||
|
||||
git fetch --prune origin
|
||||
|
||||
TARGET_REF="${{ inputs.ref }}"
|
||||
if git show-ref --verify --quiet "refs/remotes/origin/${TARGET_REF}"; then
|
||||
git checkout "$TARGET_REF"
|
||||
git reset --hard "origin/${TARGET_REF}"
|
||||
else
|
||||
git checkout "$TARGET_REF"
|
||||
fi
|
||||
|
||||
chmod +x ./scripts/deploy/vps-deploy.sh
|
||||
RUN_MIGRATIONS="${{ inputs.run_migrations }}" ./scripts/deploy/vps-deploy.sh
|
||||
39
README.md
39
README.md
@@ -31,7 +31,7 @@ pulse-score/
|
||||
|
||||
- **Backend:** Go (net/http)
|
||||
- **Frontend:** React 19, TypeScript, Vite, TailwindCSS v4
|
||||
- **Database:** PostgreSQL 16
|
||||
- **Database:** PostgreSQL 18
|
||||
- **Deployment:** Docker, Nginx, VPS
|
||||
|
||||
## Prerequisites
|
||||
@@ -72,6 +72,43 @@ npm run dev
|
||||
|
||||
The frontend starts on http://localhost:5173.
|
||||
|
||||
## Shipping to Production (VPS)
|
||||
|
||||
PulseScore ships to production through a manual GitHub Actions workflow:
|
||||
|
||||
- Workflow: `.github/workflows/deploy-prod.yml`
|
||||
- Trigger: **Actions → Deploy Production → Run workflow**
|
||||
- Inputs:
|
||||
- `ref` (branch/tag/SHA to deploy)
|
||||
- `run_migrations` (reserved; currently no-op)
|
||||
|
||||
### Required GitHub repository secrets
|
||||
|
||||
- `VPS_HOST` — production server hostname/IP
|
||||
- `VPS_USER` — SSH user on the VPS
|
||||
- `VPS_SSH_KEY` — private SSH key for deploy user
|
||||
- `VPS_APP_DIR` — absolute path to repo on VPS (example: `/opt/pulse-score`)
|
||||
|
||||
Optional:
|
||||
|
||||
- `VPS_PORT` — SSH port (defaults to `22` if omitted by your SSH client/server config)
|
||||
|
||||
### What deployment does
|
||||
|
||||
The deploy workflow SSHes into your VPS and:
|
||||
|
||||
1. Checks out the requested `ref`
|
||||
2. Ensures the external Docker network `web` exists
|
||||
3. Pulls latest DB image and rebuilds API/Web images with `--pull`
|
||||
4. Runs `docker compose -f docker-compose.prod.yml up -d --remove-orphans`
|
||||
5. Verifies DB readiness (`pg_isready`) and API health (`/healthz`)
|
||||
|
||||
### Manual deploy fallback (on VPS)
|
||||
|
||||
If needed, you can run the same deploy logic directly on the server:
|
||||
|
||||
`./scripts/deploy/vps-deploy.sh`
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Backend (Makefile)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:18-alpine
|
||||
container_name: pulsescore-postgres
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:18-alpine
|
||||
container_name: pulsescore-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
68
scripts/deploy/vps-deploy.sh
Executable file
68
scripts/deploy/vps-deploy.sh
Executable file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
log() {
|
||||
printf "\n[%s] %s\n" "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" "$*"
|
||||
}
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
DEPLOY_REF="${DEPLOY_REF:-}"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}"
|
||||
RUN_MIGRATIONS="${RUN_MIGRATIONS:-false}"
|
||||
|
||||
if [[ -n "$DEPLOY_REF" ]]; then
|
||||
log "Syncing repository to ref: $DEPLOY_REF"
|
||||
git fetch --prune origin
|
||||
|
||||
if git show-ref --verify --quiet "refs/remotes/origin/${DEPLOY_REF}"; then
|
||||
git checkout "$DEPLOY_REF"
|
||||
git reset --hard "origin/${DEPLOY_REF}"
|
||||
else
|
||||
# Allows deploying tags/commit SHAs as well.
|
||||
git checkout "$DEPLOY_REF"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -f ".env" ]]; then
|
||||
log "Loading environment from .env"
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
if ! docker network inspect web >/dev/null 2>&1; then
|
||||
log "Creating external docker network: web"
|
||||
docker network create web
|
||||
fi
|
||||
|
||||
log "Pulling latest database image"
|
||||
docker compose -f "$COMPOSE_FILE" pull db || true
|
||||
|
||||
log "Building application images"
|
||||
docker compose -f "$COMPOSE_FILE" build --pull api web
|
||||
|
||||
log "Starting production stack"
|
||||
docker compose -f "$COMPOSE_FILE" up -d --remove-orphans
|
||||
|
||||
if [[ "$RUN_MIGRATIONS" == "true" ]]; then
|
||||
log "RUN_MIGRATIONS=true requested. No automated migration command is configured; skipping."
|
||||
fi
|
||||
|
||||
log "Container status"
|
||||
docker compose -f "$COMPOSE_FILE" ps
|
||||
|
||||
if [[ -n "${POSTGRES_USER:-}" && -n "${POSTGRES_DB:-}" ]]; then
|
||||
log "Checking database readiness"
|
||||
docker compose -f "$COMPOSE_FILE" exec -T db \
|
||||
pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"
|
||||
fi
|
||||
|
||||
log "Checking API health endpoint"
|
||||
docker compose -f "$COMPOSE_FILE" exec -T api \
|
||||
wget --no-verbose --tries=1 --spider http://localhost:8080/healthz
|
||||
|
||||
log "Production deployment completed successfully."
|
||||
@@ -7,17 +7,13 @@ import {
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
import api from "@/lib/api";
|
||||
import ChartSkeleton from "@/components/skeletons/ChartSkeleton";
|
||||
import EmptyState from "@/components/EmptyState";
|
||||
import { PieChart as PieChartIcon } from "lucide-react";
|
||||
|
||||
interface BucketData {
|
||||
range: string;
|
||||
count: number;
|
||||
min_score: number;
|
||||
max_score: number;
|
||||
}
|
||||
import {
|
||||
getScoreDistributionCached,
|
||||
type ScoreDistributionBucket,
|
||||
} from "@/lib/scoreDistribution";
|
||||
|
||||
interface RiskSegment {
|
||||
name: string;
|
||||
@@ -25,7 +21,7 @@ interface RiskSegment {
|
||||
color: string;
|
||||
}
|
||||
|
||||
function aggregateRisk(buckets: BucketData[]): RiskSegment[] {
|
||||
function aggregateRisk(buckets: ScoreDistributionBucket[]): RiskSegment[] {
|
||||
let healthy = 0;
|
||||
let atRisk = 0;
|
||||
let critical = 0;
|
||||
@@ -55,9 +51,8 @@ export default function RiskDistributionChart() {
|
||||
useEffect(() => {
|
||||
async function fetch() {
|
||||
try {
|
||||
const { data: res } = await api.get("/dashboard/score-distribution");
|
||||
const buckets = res.buckets ?? res.distribution ?? res ?? [];
|
||||
if (Array.isArray(buckets) && buckets.length > 0) {
|
||||
const buckets = await getScoreDistributionCached();
|
||||
if (buckets.length > 0) {
|
||||
const agg = aggregateRisk(buckets);
|
||||
if (agg.length > 0) {
|
||||
setSegments(agg);
|
||||
|
||||
@@ -9,17 +9,13 @@ import {
|
||||
Cell,
|
||||
LabelList,
|
||||
} from "recharts";
|
||||
import api from "@/lib/api";
|
||||
import ChartSkeleton from "@/components/skeletons/ChartSkeleton";
|
||||
import EmptyState from "@/components/EmptyState";
|
||||
import { BarChart3 } from "lucide-react";
|
||||
|
||||
interface BucketData {
|
||||
range: string;
|
||||
count: number;
|
||||
min_score: number;
|
||||
max_score: number;
|
||||
}
|
||||
import {
|
||||
getScoreDistributionCached,
|
||||
type ScoreDistributionBucket,
|
||||
} from "@/lib/scoreDistribution";
|
||||
|
||||
function getBarColor(minScore: number): string {
|
||||
if (minScore >= 70) return "var(--chart-risk-healthy)";
|
||||
@@ -28,17 +24,17 @@ function getBarColor(minScore: number): string {
|
||||
}
|
||||
|
||||
export default function ScoreDistributionChart() {
|
||||
const [data, setData] = useState<BucketData[]>([]);
|
||||
const [data, setData] = useState<ScoreDistributionBucket[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [empty, setEmpty] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetch() {
|
||||
try {
|
||||
const { data: res } = await api.get("/dashboard/score-distribution");
|
||||
const buckets = res.buckets ?? res.distribution ?? res ?? [];
|
||||
if (Array.isArray(buckets) && buckets.length > 0) {
|
||||
const buckets = await getScoreDistributionCached();
|
||||
if (buckets.length > 0) {
|
||||
setData(buckets);
|
||||
setEmpty(false);
|
||||
} else {
|
||||
setEmpty(true);
|
||||
}
|
||||
|
||||
@@ -11,11 +11,11 @@ function statusFor(
|
||||
index: number,
|
||||
stepId: OnboardingStepId,
|
||||
currentStepIndex: number,
|
||||
completedSteps: OnboardingStepId[],
|
||||
skippedSteps: OnboardingStepId[],
|
||||
completedSteps: Set<OnboardingStepId>,
|
||||
skippedSteps: Set<OnboardingStepId>,
|
||||
) {
|
||||
if (completedSteps.includes(stepId)) return "completed";
|
||||
if (skippedSteps.includes(stepId)) return "skipped";
|
||||
if (completedSteps.has(stepId)) return "completed";
|
||||
if (skippedSteps.has(stepId)) return "skipped";
|
||||
if (index === currentStepIndex) return "active";
|
||||
if (index < currentStepIndex) return "past";
|
||||
return "upcoming";
|
||||
@@ -27,6 +27,9 @@ export default function WizardProgress({
|
||||
completedSteps,
|
||||
skippedSteps,
|
||||
}: WizardProgressProps) {
|
||||
const completedStepSet = new Set(completedSteps);
|
||||
const skippedStepSet = new Set(skippedSteps);
|
||||
|
||||
return (
|
||||
<ol className="mb-6 grid grid-cols-1 gap-3 sm:grid-cols-5">
|
||||
{steps.map((step, idx) => {
|
||||
@@ -34,8 +37,8 @@ export default function WizardProgress({
|
||||
idx,
|
||||
step.id,
|
||||
currentStepIndex,
|
||||
completedSteps,
|
||||
skippedSteps,
|
||||
completedStepSet,
|
||||
skippedStepSet,
|
||||
);
|
||||
|
||||
const tone =
|
||||
|
||||
@@ -38,6 +38,25 @@ export function useAuth(): AuthContextValue {
|
||||
|
||||
// Store refresh token in memory (not localStorage, for XSS protection)
|
||||
let refreshTokenStore: string | null = null;
|
||||
let refreshInFlight: Promise<AuthResponse> | null = null;
|
||||
|
||||
async function refreshSessionSingleFlight(): Promise<AuthResponse> {
|
||||
if (!refreshTokenStore) {
|
||||
throw new Error("No refresh token available");
|
||||
}
|
||||
|
||||
if (!refreshInFlight) {
|
||||
const token = refreshTokenStore;
|
||||
refreshInFlight = authApi
|
||||
.refresh(token)
|
||||
.then(({ data }) => data)
|
||||
.finally(() => {
|
||||
refreshInFlight = null;
|
||||
});
|
||||
}
|
||||
|
||||
return refreshInFlight;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<AuthState>({
|
||||
@@ -52,6 +71,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const clearSession = useCallback(() => {
|
||||
setState({ user: null, organization: null, accessToken: null });
|
||||
refreshTokenStore = null;
|
||||
refreshInFlight = null;
|
||||
if (refreshTimer.current) {
|
||||
clearTimeout(refreshTimer.current);
|
||||
refreshTimer.current = null;
|
||||
@@ -59,7 +79,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const scheduleRefresh = useCallback(
|
||||
(accessToken: string, refreshToken: string) => {
|
||||
(accessToken: string) => {
|
||||
if (refreshTimer.current) clearTimeout(refreshTimer.current);
|
||||
|
||||
// Decode JWT to get exp — schedule refresh 1 min before expiry
|
||||
@@ -71,7 +91,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
if (refreshAt > 0) {
|
||||
refreshTimer.current = setTimeout(async () => {
|
||||
try {
|
||||
const { data } = await authApi.refresh(refreshToken);
|
||||
const data = await refreshSessionSingleFlight();
|
||||
applySessionRef.current(data);
|
||||
} catch {
|
||||
clearSession();
|
||||
@@ -93,7 +113,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
accessToken: data.tokens.access_token,
|
||||
});
|
||||
refreshTokenStore = data.tokens.refresh_token;
|
||||
scheduleRefresh(data.tokens.access_token, data.tokens.refresh_token);
|
||||
scheduleRefresh(data.tokens.access_token);
|
||||
},
|
||||
[scheduleRefresh],
|
||||
);
|
||||
@@ -110,7 +130,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await authApi.refresh(refreshTokenStore);
|
||||
const data = await refreshSessionSingleFlight();
|
||||
applySession(data);
|
||||
} catch {
|
||||
clearSession();
|
||||
@@ -140,13 +160,23 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const responseId = api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
if (error.response?.status === 401 && refreshTokenStore) {
|
||||
const originalRequest = error.config;
|
||||
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
refreshTokenStore &&
|
||||
originalRequest &&
|
||||
!originalRequest._retry
|
||||
) {
|
||||
originalRequest._retry = true;
|
||||
try {
|
||||
const { data } = await authApi.refresh(refreshTokenStore);
|
||||
const data = await refreshSessionSingleFlight();
|
||||
applySession(data);
|
||||
// Retry original request with new token
|
||||
error.config.headers.Authorization = `Bearer ${data.tokens.access_token}`;
|
||||
return api.request(error.config);
|
||||
originalRequest.headers = originalRequest.headers ?? {};
|
||||
originalRequest.headers.Authorization =
|
||||
`Bearer ${data.tokens.access_token}`;
|
||||
return api.request(originalRequest);
|
||||
} catch {
|
||||
clearSession();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
@@ -56,12 +57,21 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
[removeToast],
|
||||
);
|
||||
|
||||
const value: ToastContextValue = {
|
||||
success: useCallback((msg: string) => addToast("success", msg), [addToast]),
|
||||
error: useCallback((msg: string) => addToast("error", msg), [addToast]),
|
||||
warning: useCallback((msg: string) => addToast("warning", msg), [addToast]),
|
||||
info: useCallback((msg: string) => addToast("info", msg), [addToast]),
|
||||
};
|
||||
const success = useCallback((msg: string) => addToast("success", msg), [
|
||||
addToast,
|
||||
]);
|
||||
const error = useCallback((msg: string) => addToast("error", msg), [
|
||||
addToast,
|
||||
]);
|
||||
const warning = useCallback((msg: string) => addToast("warning", msg), [
|
||||
addToast,
|
||||
]);
|
||||
const info = useCallback((msg: string) => addToast("info", msg), [addToast]);
|
||||
|
||||
const value = useMemo<ToastContextValue>(
|
||||
() => ({ success, error, warning, info }),
|
||||
[success, error, warning, info],
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
|
||||
88
web/src/lib/scoreDistribution.ts
Normal file
88
web/src/lib/scoreDistribution.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import api from "@/lib/api";
|
||||
|
||||
export interface ScoreDistributionBucket {
|
||||
range: string;
|
||||
count: number;
|
||||
min_score: number;
|
||||
max_score: number;
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 30_000;
|
||||
|
||||
let cachedBuckets: ScoreDistributionBucket[] | null = null;
|
||||
let lastFetchedAtMs = 0;
|
||||
let inFlightRequest: Promise<ScoreDistributionBucket[]> | null = null;
|
||||
|
||||
function toNumber(value: unknown, fallback = 0): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function extractRawBuckets(responseData: unknown): unknown {
|
||||
if (Array.isArray(responseData)) return responseData;
|
||||
if (responseData && typeof responseData === "object") {
|
||||
const record = responseData as Record<string, unknown>;
|
||||
return record.buckets ?? record.distribution ?? [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeBuckets(rawBuckets: unknown): ScoreDistributionBucket[] {
|
||||
if (!Array.isArray(rawBuckets)) return [];
|
||||
|
||||
return rawBuckets.map((bucket, index) => {
|
||||
const record =
|
||||
bucket && typeof bucket === "object"
|
||||
? (bucket as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const minScore = toNumber(record.min_score, index * 10);
|
||||
const maxScore = toNumber(record.max_score, minScore + 9);
|
||||
|
||||
return {
|
||||
range: String(record.range ?? `${minScore}-${maxScore}`),
|
||||
count: toNumber(record.count),
|
||||
min_score: minScore,
|
||||
max_score: maxScore,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getScoreDistributionCached(options?: { force?: boolean }) {
|
||||
const force = options?.force === true;
|
||||
|
||||
if (force) {
|
||||
cachedBuckets = null;
|
||||
lastFetchedAtMs = 0;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (
|
||||
!force &&
|
||||
cachedBuckets &&
|
||||
now - lastFetchedAtMs < CACHE_TTL_MS
|
||||
) {
|
||||
return cachedBuckets;
|
||||
}
|
||||
|
||||
if (inFlightRequest) {
|
||||
return inFlightRequest;
|
||||
}
|
||||
|
||||
inFlightRequest = (async () => {
|
||||
const { data } = await api.get("/dashboard/score-distribution");
|
||||
const buckets = normalizeBuckets(extractRawBuckets(data));
|
||||
cachedBuckets = buckets;
|
||||
lastFetchedAtMs = Date.now();
|
||||
return buckets;
|
||||
})().finally(() => {
|
||||
inFlightRequest = null;
|
||||
});
|
||||
|
||||
return inFlightRequest;
|
||||
}
|
||||
|
||||
export function invalidateScoreDistributionCache() {
|
||||
cachedBuckets = null;
|
||||
lastFetchedAtMs = 0;
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useState } from "react";
|
||||
import api from "@/lib/api";
|
||||
import { useToast } from "@/contexts/ToastContext";
|
||||
import StatCard from "@/components/StatCard";
|
||||
import CardSkeleton from "@/components/skeletons/CardSkeleton";
|
||||
import ScoreDistributionChart from "@/components/charts/ScoreDistributionChart";
|
||||
import MRRTrendChart from "@/components/charts/MRRTrendChart";
|
||||
import RiskDistributionChart from "@/components/charts/RiskDistributionChart";
|
||||
import AtRiskCustomersTable from "@/components/AtRiskCustomersTable";
|
||||
import { Users, AlertTriangle, DollarSign, Activity } from "lucide-react";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
|
||||
const ScoreDistributionChart = lazy(
|
||||
() => import("@/components/charts/ScoreDistributionChart"),
|
||||
);
|
||||
const MRRTrendChart = lazy(() => import("@/components/charts/MRRTrendChart"));
|
||||
const RiskDistributionChart = lazy(
|
||||
() => import("@/components/charts/RiskDistributionChart"),
|
||||
);
|
||||
const AtRiskCustomersTable = lazy(
|
||||
() => import("@/components/AtRiskCustomersTable"),
|
||||
);
|
||||
|
||||
interface DashboardSummary {
|
||||
total_customers: number;
|
||||
at_risk_customers: number;
|
||||
@@ -17,6 +24,26 @@ interface DashboardSummary {
|
||||
average_health_score: number;
|
||||
}
|
||||
|
||||
function ChartPanelFallback() {
|
||||
return <CardSkeleton />;
|
||||
}
|
||||
|
||||
function TablePanelFallback() {
|
||||
return (
|
||||
<div className="galdr-card p-6">
|
||||
<div className="h-4 w-40 animate-pulse rounded bg-[color-mix(in_srgb,var(--galdr-fg-muted)_35%,transparent)]" />
|
||||
<div className="mt-4 space-y-2">
|
||||
{[...Array(4)].map((_, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="h-10 animate-pulse rounded bg-[color-mix(in_srgb,var(--galdr-fg-muted)_25%,transparent)]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -90,15 +117,23 @@ export default function DashboardPage() {
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<ScoreDistributionChart />
|
||||
<MRRTrendChart />
|
||||
<Suspense fallback={<ChartPanelFallback />}>
|
||||
<ScoreDistributionChart />
|
||||
</Suspense>
|
||||
<Suspense fallback={<ChartPanelFallback />}>
|
||||
<MRRTrendChart />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Risk overview */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<RiskDistributionChart />
|
||||
<Suspense fallback={<ChartPanelFallback />}>
|
||||
<RiskDistributionChart />
|
||||
</Suspense>
|
||||
<div className="lg:col-span-2">
|
||||
<AtRiskCustomersTable />
|
||||
<Suspense fallback={<TablePanelFallback />}>
|
||||
<AtRiskCustomersTable />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
notificationPreferencesApi,
|
||||
alertsApi,
|
||||
@@ -14,6 +14,9 @@ export default function NotificationsTab() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const toast = useToast();
|
||||
const mutedSet = useMemo(() => new Set(prefs?.muted_rule_ids ?? []), [
|
||||
prefs?.muted_rule_ids,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
@@ -72,16 +75,19 @@ export default function NotificationsTab() {
|
||||
async function toggleMuteRule(ruleId: string) {
|
||||
if (!prefs) return;
|
||||
setSaving(true);
|
||||
|
||||
const currentlyMuted = mutedSet.has(ruleId);
|
||||
const muted = prefs.muted_rule_ids ?? [];
|
||||
const newMuted = muted.includes(ruleId)
|
||||
const newMuted = currentlyMuted
|
||||
? muted.filter((id) => id !== ruleId)
|
||||
: [...muted, ruleId];
|
||||
|
||||
try {
|
||||
const { data } = await notificationPreferencesApi.update({
|
||||
muted_rule_ids: newMuted,
|
||||
});
|
||||
setPrefs(data);
|
||||
toast.success(muted.includes(ruleId) ? "Rule unmuted" : "Rule muted");
|
||||
toast.success(currentlyMuted ? "Rule unmuted" : "Rule muted");
|
||||
} catch {
|
||||
toast.error("Failed to update preferences");
|
||||
} finally {
|
||||
@@ -179,7 +185,7 @@ export default function NotificationsTab() {
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{rules.map((rule) => {
|
||||
const isMuted = (prefs.muted_rule_ids ?? []).includes(rule.id);
|
||||
const isMuted = mutedSet.has(rule.id);
|
||||
return (
|
||||
<div
|
||||
key={rule.id}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import {
|
||||
Navigate,
|
||||
Route,
|
||||
@@ -5,17 +6,32 @@ import {
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from "react-router-dom";
|
||||
import OrganizationTab from "@/pages/settings/OrganizationTab";
|
||||
import ProfileTab from "@/pages/settings/ProfileTab";
|
||||
import IntegrationsTab from "@/pages/settings/IntegrationsTab";
|
||||
import ScoringTab from "@/pages/settings/ScoringTab";
|
||||
import BillingTab from "@/pages/settings/BillingTab";
|
||||
import TeamTab from "@/pages/settings/TeamTab";
|
||||
import AlertsTab from "@/pages/settings/AlertsTab";
|
||||
import NotificationsTab from "@/pages/settings/NotificationsTab";
|
||||
import StripeCallbackPage from "@/pages/settings/StripeCallbackPage";
|
||||
import HubSpotCallbackPage from "@/pages/settings/HubSpotCallbackPage";
|
||||
import IntercomCallbackPage from "@/pages/settings/IntercomCallbackPage";
|
||||
|
||||
const OrganizationTab = lazy(() => import("@/pages/settings/OrganizationTab"));
|
||||
const ProfileTab = lazy(() => import("@/pages/settings/ProfileTab"));
|
||||
const IntegrationsTab = lazy(() => import("@/pages/settings/IntegrationsTab"));
|
||||
const ScoringTab = lazy(() => import("@/pages/settings/ScoringTab"));
|
||||
const BillingTab = lazy(() => import("@/pages/settings/BillingTab"));
|
||||
const TeamTab = lazy(() => import("@/pages/settings/TeamTab"));
|
||||
const AlertsTab = lazy(() => import("@/pages/settings/AlertsTab"));
|
||||
const NotificationsTab = lazy(() => import("@/pages/settings/NotificationsTab"));
|
||||
const StripeCallbackPage = lazy(
|
||||
() => import("@/pages/settings/StripeCallbackPage"),
|
||||
);
|
||||
const HubSpotCallbackPage = lazy(
|
||||
() => import("@/pages/settings/HubSpotCallbackPage"),
|
||||
);
|
||||
const IntercomCallbackPage = lazy(
|
||||
() => import("@/pages/settings/IntercomCallbackPage"),
|
||||
);
|
||||
|
||||
function SettingsTabFallback() {
|
||||
return (
|
||||
<div className="flex min-h-[20vh] items-center justify-center text-sm text-[var(--galdr-fg-muted)]">
|
||||
Loading settings…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ path: "organization", label: "Organization" },
|
||||
@@ -65,20 +81,22 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<Routes>
|
||||
<Route path="organization" element={<OrganizationTab />} />
|
||||
<Route path="profile" element={<ProfileTab />} />
|
||||
<Route path="integrations" element={<IntegrationsTab />} />
|
||||
<Route path="scoring" element={<ScoringTab />} />
|
||||
<Route path="billing" element={<BillingTab />} />
|
||||
<Route path="team" element={<TeamTab />} />
|
||||
<Route path="alerts" element={<AlertsTab />} />
|
||||
<Route path="notifications" element={<NotificationsTab />} />
|
||||
<Route path="stripe/callback" element={<StripeCallbackPage />} />
|
||||
<Route path="hubspot/callback" element={<HubSpotCallbackPage />} />
|
||||
<Route path="intercom/callback" element={<IntercomCallbackPage />} />
|
||||
<Route index element={<Navigate to="organization" replace />} />
|
||||
</Routes>
|
||||
<Suspense fallback={<SettingsTabFallback />}>
|
||||
<Routes>
|
||||
<Route path="organization" element={<OrganizationTab />} />
|
||||
<Route path="profile" element={<ProfileTab />} />
|
||||
<Route path="integrations" element={<IntegrationsTab />} />
|
||||
<Route path="scoring" element={<ScoringTab />} />
|
||||
<Route path="billing" element={<BillingTab />} />
|
||||
<Route path="team" element={<TeamTab />} />
|
||||
<Route path="alerts" element={<AlertsTab />} />
|
||||
<Route path="notifications" element={<NotificationsTab />} />
|
||||
<Route path="stripe/callback" element={<StripeCallbackPage />} />
|
||||
<Route path="hubspot/callback" element={<HubSpotCallbackPage />} />
|
||||
<Route path="intercom/callback" element={<IntercomCallbackPage />} />
|
||||
<Route index element={<Navigate to="organization" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user