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:
@@ -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
144
backend/src/routes/k8s.ts
Normal 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;
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user