CROP
ProjectsParts Services

Admin Environment Switcher - Feature Plan

Admin panel feature for switching data environments and managing K&M Tire integration.

Admin Environment Switcher - Feature Plan

Overview

Admin panel feature for switching data environments and managing K&M Tire integration.

Goals

  1. Environment Switching: Switch between crop_dev, crop_stage, crop_prod databases
  2. K&M Tire Toggle: Enable/disable K&M Tire data in the search index
  3. Index Rebuild: Trigger Elasticsearch index rebuild from admin UI
  4. Status Monitoring: View current configuration and rebuild progress

Architecture

Current State

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   CROP-Front    │────▶│  Search Service │────▶│  Elasticsearch  │
│   (Next.js)     │     │    (Hono)       │     │     Index       │
└─────────────────┘     └─────────────────┘     └─────────────────┘


                        ┌─────────────────┐
                        │    MongoDB      │
                        │  (crop_dev)     │
                        └─────────────────┘

Target State

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   CROP-Front    │────▶│  Search Service │────▶│  Elasticsearch  │
│   Admin Panel   │     │   Admin API     │     │     Index       │
└─────────────────┘     └─────────────────┘     └─────────────────┘
        │                      │
        │                      ▼
        │               ┌─────────────────┐
        │               │    MongoDB      │
        │               ├─────────────────┤
        │               │ • crop_dev      │
        │               │ • crop_stage    │
        │               │ • crop_prod     │
        │               └─────────────────┘
        │                      │
        │                      ▼
        │               ┌─────────────────┐
        └──────────────▶│ Catalog Service │
                        │   (K&M Tire)    │
                        └─────────────────┘

API Design

Search Service Admin Endpoints

1. Get Current Configuration

GET /api/admin/index-config
Response: {
  environment: "crop_dev" | "crop_stage" | "crop_prod",
  indexName: "parts_current",
  aliasTarget: "parts_v2026-01-16t0741",
  documentCount: 1639,
  manufacturers: [
    { code: "BNS", name: "Briggs & Stratton", count: 1313 },
    { code: "VNT", name: "Ventrac", count: 211 },
    { code: "KMT", name: "K&M Tire", count: 109, enabled: true },
    { code: "NHL", name: "New Holland", count: 5 }
  ],
  lastRebuild: "2026-01-16T07:41:00Z",
  collections: ["parts_nhl", "parts_bns", "parts_vnt", "parts_kmt"]
}

2. Trigger Index Rebuild

POST /api/admin/rebuild-index
Body: {
  environment: "crop_dev" | "crop_stage" | "crop_prod",
  includeKmTire: true,
  collections?: string[]  // Optional: specific collections to include
}
Response: {
  jobId: "rebuild-2026-01-16-abc123",
  status: "started",
  estimatedDuration: "2-3 minutes"
}

3. Get Rebuild Status

GET /api/admin/rebuild-status/{jobId}
Response: {
  jobId: "rebuild-2026-01-16-abc123",
  status: "in_progress" | "completed" | "failed",
  progress: {
    phase: "indexing",
    current: 1200,
    total: 1639,
    percentage: 73
  },
  startedAt: "2026-01-16T07:41:00Z",
  completedAt: null,
  error: null
}

4. List Available Environments

GET /api/admin/environments
Response: {
  environments: [
    {
      id: "crop_dev",
      name: "Development",
      collections: ["parts_nhl", "parts_bns", "parts_vnt", "parts_kmt"],
      totalDocuments: 1639,
      lastSync: "2026-01-16T06:57:00Z"
    },
    {
      id: "crop_stage",
      name: "Staging (Small Dataset)",
      collections: ["parts_nhl", "parts_bns", "parts_vnt", "parts_kmt"],
      totalDocuments: 118,
      lastSync: "2026-01-15T12:00:00Z"
    },
    {
      id: "crop_prod",
      name: "Production",
      collections: ["parts_nhl", "parts_bns", "parts_vnt"],
      totalDocuments: 12500,
      lastSync: "2026-01-16T00:00:00Z"
    }
  ],
  current: "crop_dev"
}

Backend Implementation

File Structure

services/search/src/
├── routes/
│   └── admin.ts           # Existing admin routes
│   └── admin-index.ts     # NEW: Index management routes
├── services/
│   └── index-rebuild.ts   # NEW: Index rebuild service
│   └── env-manager.ts     # NEW: Environment management
├── jobs/
│   └── rebuild-job.ts     # NEW: Background rebuild job
└── schemas/
    └── admin-index.ts     # NEW: Request/response schemas

Key Components

1. Environment Manager (env-manager.ts)

const ENVIRONMENTS = {
  crop_dev: {
    uri: process.env.MONGODB_URI_DEV,
    collections: ['parts_nhl', 'parts_bns', 'parts_vnt', 'parts_kmt']
  },
  crop_stage: {
    uri: process.env.MONGODB_URI_STAGE,
    collections: ['parts_nhl', 'parts_bns', 'parts_vnt', 'parts_kmt']
  },
  crop_prod: {
    uri: process.env.MONGODB_URI_PROD,
    collections: ['parts_nhl', 'parts_bns', 'parts_vnt']
  }
} as const;

export async function getEnvironmentStats(envId: string): Promise<EnvStats> {
  // Connect to specified MongoDB, count documents per collection
}

export async function switchEnvironment(envId: string): Promise<void> {
  // Update current environment in memory/config
}

2. Index Rebuild Service (index-rebuild.ts)

interface RebuildOptions {
  environment: 'crop_dev' | 'crop_stage' | 'crop_prod';
  includeKmTire: boolean;
  collections?: string[];
}

interface RebuildJob {
  id: string;
  status: 'pending' | 'in_progress' | 'completed' | 'failed';
  progress: { current: number; total: number; phase: string };
  startedAt: Date;
  completedAt?: Date;
  error?: string;
}

// In-memory job tracking (or use Redis for multi-instance)
const activeJobs = new Map<string, RebuildJob>();

export async function startRebuild(options: RebuildOptions): Promise<string> {
  const jobId = `rebuild-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;

  // Create job entry
  activeJobs.set(jobId, {
    id: jobId,
    status: 'pending',
    progress: { current: 0, total: 0, phase: 'initializing' },
    startedAt: new Date()
  });

  // Start background rebuild (non-blocking)
  runRebuildInBackground(jobId, options);

  return jobId;
}

export function getJobStatus(jobId: string): RebuildJob | null {
  return activeJobs.get(jobId) || null;
}

3. Background Rebuild Logic

async function runRebuildInBackground(jobId: string, options: RebuildOptions) {
  const job = activeJobs.get(jobId)!;

  try {
    job.status = 'in_progress';
    job.progress.phase = 'connecting';

    // 1. Connect to source MongoDB
    const db = await connectToEnvironment(options.environment);

    // 2. Count total documents
    const collections = options.collections ||
      ENVIRONMENTS[options.environment].collections;

    // Filter out K&M Tire if disabled
    const filteredCollections = options.includeKmTire
      ? collections
      : collections.filter(c => c !== 'parts_kmt');

    let totalDocs = 0;
    for (const coll of filteredCollections) {
      totalDocs += await db.collection(coll).countDocuments();
    }
    job.progress.total = totalDocs;

    // 3. Create new versioned index
    const timestamp = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
    const newIndexName = `parts_v${timestamp}`;
    job.progress.phase = 'creating_index';
    await createIndex(newIndexName);

    // 4. Index documents with progress updates
    job.progress.phase = 'indexing';
    let indexed = 0;

    for (const collName of filteredCollections) {
      const cursor = db.collection(collName).find();
      const batch = [];

      for await (const doc of cursor) {
        const transformed = passthroughTransform(doc);
        if (transformed) {
          batch.push(transformed);

          if (batch.length >= 500) {
            await bulkIndex(newIndexName, batch);
            indexed += batch.length;
            job.progress.current = indexed;
            batch.length = 0;
          }
        }
      }

      // Index remaining
      if (batch.length > 0) {
        await bulkIndex(newIndexName, batch);
        indexed += batch.length;
        job.progress.current = indexed;
      }
    }

    // 5. Switch alias
    job.progress.phase = 'switching_alias';
    await switchAlias(newIndexName);

    // 6. Complete
    job.status = 'completed';
    job.completedAt = new Date();
    job.progress.phase = 'completed';

  } catch (error) {
    job.status = 'failed';
    job.error = error.message;
    job.completedAt = new Date();
  }
}

Frontend Implementation

File Structure

app/
├── admin/
│   └── index/
│       └── page.tsx           # Admin index management page
│   └── _components/
│       ├── environment-selector.tsx
│       ├── km-tire-toggle.tsx
│       ├── rebuild-button.tsx
│       ├── rebuild-progress.tsx
│       └── index-stats.tsx
└── api/
    └── admin/
        └── [...path]/
            └── route.ts       # Proxy to search service admin API

UI Components

1. Admin Index Page (/admin/index)

export default function AdminIndexPage() {
  return (
    <AdminLayout>
      <h1>Index Management</h1>

      <div className="grid gap-6">
        {/* Current Status Card */}
        <IndexStatsCard />

        {/* Environment Selector */}
        <EnvironmentSelector />

        {/* K&M Tire Toggle */}
        <KmTireToggle />

        {/* Rebuild Controls */}
        <RebuildControls />

        {/* Rebuild Progress (when active) */}
        <RebuildProgress />
      </div>
    </AdminLayout>
  );
}

2. Environment Selector

function EnvironmentSelector() {
  const [environments, setEnvironments] = useState<Environment[]>([]);
  const [current, setCurrent] = useState<string>('');

  return (
    <Card>
      <CardHeader>
        <CardTitle>Data Environment</CardTitle>
        <CardDescription>
          Select which MongoDB database to use for the search index
        </CardDescription>
      </CardHeader>
      <CardContent>
        <RadioGroup value={current} onValueChange={setCurrent}>
          {environments.map(env => (
            <div key={env.id} className="flex items-center space-x-4 p-4 border rounded">
              <RadioGroupItem value={env.id} />
              <div className="flex-1">
                <Label>{env.name}</Label>
                <p className="text-sm text-muted-foreground">
                  {env.totalDocuments.toLocaleString()} documents
                </p>
              </div>
              <Badge variant={env.id === current ? 'default' : 'outline'}>
                {env.id === current ? 'Active' : 'Available'}
              </Badge>
            </div>
          ))}
        </RadioGroup>
      </CardContent>
    </Card>
  );
}

3. K&M Tire Toggle

function KmTireToggle() {
  const [enabled, setEnabled] = useState(true);

  return (
    <Card>
      <CardHeader>
        <CardTitle>K&M Tire Integration</CardTitle>
        <CardDescription>
          Include K&M Tire products in the search index
        </CardDescription>
      </CardHeader>
      <CardContent>
        <div className="flex items-center justify-between">
          <div className="flex items-center gap-3">
            <div className="w-10 h-10 rounded-full bg-neutral-100 flex items-center justify-center">
              T
            </div>
            <div>
              <p className="font-medium">K&M Tire (109 products)</p>
              <p className="text-sm text-muted-foreground">
                Wholesale tire catalog with brand logos
              </p>
            </div>
          </div>
          <Switch checked={enabled} onCheckedChange={setEnabled} />
        </div>
      </CardContent>
    </Card>
  );
}

4. Rebuild Progress

function RebuildProgress({ jobId }: { jobId: string }) {
  const { data: job, isLoading } = useQuery({
    queryKey: ['rebuild-status', jobId],
    queryFn: () => fetchRebuildStatus(jobId),
    refetchInterval: job?.status === 'in_progress' ? 1000 : false
  });

  if (!job) return null;

  return (
    <Card>
      <CardHeader>
        <CardTitle className="flex items-center gap-2">
          {job.status === 'in_progress' && <Loader2 className="animate-spin" />}
          {job.status === 'completed' && <CheckCircle className="text-green-500" />}
          {job.status === 'failed' && <XCircle className="text-red-500" />}
          Index Rebuild
        </CardTitle>
      </CardHeader>
      <CardContent>
        <div className="space-y-4">
          <div>
            <div className="flex justify-between text-sm mb-1">
              <span>{job.progress.phase}</span>
              <span>{job.progress.percentage}%</span>
            </div>
            <Progress value={job.progress.percentage} />
          </div>

          <p className="text-sm text-muted-foreground">
            {job.progress.current.toLocaleString()} / {job.progress.total.toLocaleString()} documents
          </p>
        </div>
      </CardContent>
    </Card>
  );
}

Environment Variables

Search Service

# Multiple MongoDB connection strings
MONGODB_URI_DEV=mongodb+srv://...@crop-gcp.mongodb.net/crop_dev
MONGODB_URI_STAGE=mongodb+srv://...@crop-gcp.mongodb.net/crop_stage
MONGODB_URI_PROD=mongodb+srv://...@crop-gcp.mongodb.net/crop_prod

# Current active environment (set via admin or env var)
ACTIVE_ENVIRONMENT=crop_dev

# Admin authentication
ADMIN_API_TOKEN=your-secure-token

Security Considerations

  1. Authentication: All admin endpoints require X-Admin-Token header
  2. Authorization: Only users with admin role can access admin pages
  3. Rate Limiting: Limit rebuild requests to 1 per minute
  4. Audit Logging: Log all admin actions with user ID and timestamp
  5. Confirmation: Require confirmation for production environment changes

Implementation Phases

Phase 1: Backend API (2-3 hours)

  • Create admin-index.ts routes
  • Implement env-manager.ts service
  • Implement index-rebuild.ts service
  • Add background job tracking

Phase 2: Frontend UI (2-3 hours)

  • Create /admin/index page
  • Implement environment selector component
  • Implement K&M Tire toggle
  • Implement rebuild button with progress

Phase 3: Integration & Testing (1-2 hours)

  • Add API proxy route in Next.js
  • Test environment switching
  • Test rebuild functionality
  • Add error handling and edge cases

Future Enhancements

  1. Scheduled Rebuilds: Set up automatic nightly index rebuilds
  2. Diff View: Show changes before applying environment switch
  3. Rollback: One-click rollback to previous index version
  4. Webhook Notifications: Notify Slack/Discord on rebuild completion
  5. Multi-region: Support for different ES clusters per environment

On this page