feat(admin): Worker scaling controls via k8s API

- Add /api/k8s/workers endpoint to get deployment status
- Add /api/k8s/workers/scale endpoint to scale replicas (0-50)
- Add worker scaling UI to Tasks Dashboard (+/- 5 workers)
- Shows ready/desired replica count
- Uses in-cluster config in k8s, kubeconfig locally

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kelly
2025-12-11 08:24:32 -07:00
parent ced1afaa8a
commit 8206dce821
4 changed files with 227 additions and 2 deletions

View File

@@ -146,6 +146,7 @@ import tasksRoutes from './routes/tasks';
import workerRegistryRoutes from './routes/worker-registry'; import workerRegistryRoutes from './routes/worker-registry';
// Per TASK_WORKFLOW_2024-12-10.md: Raw payload access API // Per TASK_WORKFLOW_2024-12-10.md: Raw payload access API
import payloadsRoutes from './routes/payloads'; import payloadsRoutes from './routes/payloads';
import k8sRoutes from './routes/k8s';
// Mark requests from trusted domains (cannaiq.co, findagram.co, findadispo.com) // Mark requests from trusted domains (cannaiq.co, findagram.co, findadispo.com)
// These domains can access the API without authentication // These domains can access the API without authentication
@@ -230,6 +231,10 @@ console.log('[WorkerRegistry] Routes registered at /api/worker-registry');
app.use('/api/payloads', payloadsRoutes); app.use('/api/payloads', payloadsRoutes);
console.log('[Payloads] Routes registered at /api/payloads'); console.log('[Payloads] Routes registered at /api/payloads');
// K8s control routes - worker scaling from admin UI
app.use('/api/k8s', k8sRoutes);
console.log('[K8s] Routes registered at /api/k8s');
// Phase 3: Analytics V2 - Enhanced analytics with rec/med state segmentation // Phase 3: Analytics V2 - Enhanced analytics with rec/med state segmentation
try { try {
const analyticsV2Router = createAnalyticsV2Router(getPool()); const analyticsV2Router = createAnalyticsV2Router(getPool());

144
backend/src/routes/k8s.ts Normal file
View File

@@ -0,0 +1,144 @@
/**
* Kubernetes Control Routes
*
* Provides admin UI control over k8s resources like worker scaling.
* Uses in-cluster config when running in k8s, or kubeconfig locally.
*/
import { Router, Request, Response } from 'express';
import * as k8s from '@kubernetes/client-node';
const router = Router();
// K8s client setup - lazy initialization
let appsApi: k8s.AppsV1Api | null = null;
let k8sError: string | null = null;
function getK8sClient(): k8s.AppsV1Api | null {
if (appsApi) return appsApi;
if (k8sError) return null;
try {
const kc = new k8s.KubeConfig();
// Try in-cluster config first (when running in k8s)
try {
kc.loadFromCluster();
console.log('[K8s] Loaded in-cluster config');
} catch {
// Fall back to default kubeconfig (local dev)
try {
kc.loadFromDefault();
console.log('[K8s] Loaded default kubeconfig');
} catch (e) {
k8sError = 'No k8s config available';
console.log('[K8s] No config available - k8s routes disabled');
return null;
}
}
appsApi = kc.makeApiClient(k8s.AppsV1Api);
return appsApi;
} catch (e: any) {
k8sError = e.message;
console.error('[K8s] Failed to initialize client:', e.message);
return null;
}
}
const NAMESPACE = process.env.K8S_NAMESPACE || 'dispensary-scraper';
const WORKER_DEPLOYMENT = 'scraper-worker';
/**
* GET /api/k8s/workers
* Get current worker deployment status
*/
router.get('/workers', async (_req: Request, res: Response) => {
const client = getK8sClient();
if (!client) {
return res.json({
success: true,
available: false,
error: k8sError || 'K8s not available',
replicas: 0,
readyReplicas: 0,
});
}
try {
const deployment = await client.readNamespacedDeployment({
name: WORKER_DEPLOYMENT,
namespace: NAMESPACE,
});
res.json({
success: true,
available: true,
replicas: deployment.spec?.replicas || 0,
readyReplicas: deployment.status?.readyReplicas || 0,
availableReplicas: deployment.status?.availableReplicas || 0,
updatedReplicas: deployment.status?.updatedReplicas || 0,
});
} catch (e: any) {
console.error('[K8s] Error getting deployment:', e.message);
res.status(500).json({
success: false,
error: e.message,
});
}
});
/**
* POST /api/k8s/workers/scale
* Scale worker deployment
* Body: { replicas: number }
*/
router.post('/workers/scale', async (req: Request, res: Response) => {
const client = getK8sClient();
if (!client) {
return res.status(503).json({
success: false,
error: k8sError || 'K8s not available',
});
}
const { replicas } = req.body;
if (typeof replicas !== 'number' || replicas < 0 || replicas > 50) {
return res.status(400).json({
success: false,
error: 'replicas must be a number between 0 and 50',
});
}
try {
// Patch the deployment to set replicas
await client.patchNamespacedDeploymentScale({
name: WORKER_DEPLOYMENT,
namespace: NAMESPACE,
body: {
spec: { replicas },
},
}, undefined, undefined, undefined, undefined, undefined, {
headers: { 'Content-Type': 'application/strategic-merge-patch+json' },
});
console.log(`[K8s] Scaled ${WORKER_DEPLOYMENT} to ${replicas} replicas`);
res.json({
success: true,
replicas,
message: `Scaled to ${replicas} workers`,
});
} catch (e: any) {
console.error('[K8s] Error scaling deployment:', e.message);
res.status(500).json({
success: false,
error: e.message,
});
}
});
export default router;

View File

@@ -2950,6 +2950,25 @@ class ApiClient {
{ method: 'POST' } { method: 'POST' }
); );
} }
// K8s Worker Control
async getK8sWorkers() {
return this.request<{
success: boolean;
available: boolean;
replicas: number;
readyReplicas: number;
availableReplicas?: number;
error?: string;
}>('/api/k8s/workers');
}
async scaleK8sWorkers(replicas: number) {
return this.request<{ success: boolean; replicas: number; message?: string; error?: string }>(
'/api/k8s/workers/scale',
{ method: 'POST', body: JSON.stringify({ replicas }) }
);
}
} }
export const api = new ApiClient(API_URL); export const api = new ApiClient(API_URL);

View File

@@ -14,9 +14,11 @@ import {
ChevronUp, ChevronUp,
Gauge, Gauge,
Users, Users,
Power,
Play, Play,
Square, Square,
Plus,
Minus,
Server,
} from 'lucide-react'; } from 'lucide-react';
interface Task { interface Task {
@@ -141,6 +143,12 @@ export default function TasksDashboard() {
const [poolPaused, setPoolPaused] = useState(false); const [poolPaused, setPoolPaused] = useState(false);
const [poolLoading, setPoolLoading] = useState(false); const [poolLoading, setPoolLoading] = useState(false);
// K8s worker state
const [k8sAvailable, setK8sAvailable] = useState(false);
const [workerReplicas, setWorkerReplicas] = useState(0);
const [workerReady, setWorkerReady] = useState(0);
const [scalingWorkers, setScalingWorkers] = useState(false);
// Filters // Filters
const [roleFilter, setRoleFilter] = useState<string>(''); const [roleFilter, setRoleFilter] = useState<string>('');
const [statusFilter, setStatusFilter] = useState<string>(''); const [statusFilter, setStatusFilter] = useState<string>('');
@@ -150,7 +158,7 @@ export default function TasksDashboard() {
const fetchData = async () => { const fetchData = async () => {
try { try {
const [tasksRes, countsRes, capacityRes, poolStatus] = await Promise.all([ const [tasksRes, countsRes, capacityRes, poolStatus, k8sRes] = await Promise.all([
api.getTasks({ api.getTasks({
role: roleFilter || undefined, role: roleFilter || undefined,
status: statusFilter || undefined, status: statusFilter || undefined,
@@ -159,12 +167,16 @@ export default function TasksDashboard() {
api.getTaskCounts(), api.getTaskCounts(),
api.getTaskCapacity(), api.getTaskCapacity(),
api.getTaskPoolStatus(), api.getTaskPoolStatus(),
api.getK8sWorkers(),
]); ]);
setTasks(tasksRes.tasks || []); setTasks(tasksRes.tasks || []);
setCounts(countsRes); setCounts(countsRes);
setCapacity(capacityRes.metrics || []); setCapacity(capacityRes.metrics || []);
setPoolPaused(poolStatus.paused); setPoolPaused(poolStatus.paused);
setK8sAvailable(k8sRes.available);
setWorkerReplicas(k8sRes.replicas);
setWorkerReady(k8sRes.readyReplicas);
setError(null); setError(null);
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Failed to load tasks'); setError(err.message || 'Failed to load tasks');
@@ -173,6 +185,25 @@ export default function TasksDashboard() {
} }
}; };
const scaleWorkers = async (delta: number) => {
const newReplicas = Math.max(0, Math.min(50, workerReplicas + delta));
if (newReplicas === workerReplicas) return;
setScalingWorkers(true);
try {
const res = await api.scaleK8sWorkers(newReplicas);
if (res.success) {
setWorkerReplicas(res.replicas);
} else {
setError(res.error || 'Failed to scale workers');
}
} catch (err: any) {
setError(err.message || 'Failed to scale workers');
} finally {
setScalingWorkers(false);
}
};
const togglePool = async () => { const togglePool = async () => {
setPoolLoading(true); setPoolLoading(true);
try { try {
@@ -238,6 +269,32 @@ export default function TasksDashboard() {
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Worker Scaling */}
{k8sAvailable && (
<div className="flex items-center gap-2 px-3 py-2 bg-gray-100 rounded-lg">
<Server className="w-4 h-4 text-gray-500" />
<button
onClick={() => scaleWorkers(-5)}
disabled={scalingWorkers || workerReplicas === 0}
className="p-1 hover:bg-gray-200 rounded disabled:opacity-50"
title="Remove 5 workers"
>
<Minus className="w-4 h-4" />
</button>
<span className={`min-w-[60px] text-center font-mono ${scalingWorkers ? 'animate-pulse' : ''}`}>
{workerReady}/{workerReplicas}
</span>
<button
onClick={() => scaleWorkers(5)}
disabled={scalingWorkers || workerReplicas >= 50}
className="p-1 hover:bg-gray-200 rounded disabled:opacity-50"
title="Add 5 workers"
>
<Plus className="w-4 h-4" />
</button>
</div>
)}
{/* Pool Toggle */} {/* Pool Toggle */}
<button <button
onClick={togglePool} onClick={togglePool}