Merge pull request 'feat: Worker scaling from admin UI with password confirmation' (#33) from feat/worker-scaling into master

Reviewed-on: https://code.cannabrands.app/Creationshop/dispensary-scraper/pulls/33
This commit is contained in:
kelly
2025-12-11 16:29:04 +00:00
6 changed files with 273 additions and 39 deletions

View File

@@ -47,4 +47,27 @@ router.post('/refresh', authMiddleware, async (req: AuthRequest, res) => {
res.json({ token });
});
// Verify password for sensitive actions (requires current user to be authenticated)
router.post('/verify-password', authMiddleware, async (req: AuthRequest, res) => {
try {
const { password } = req.body;
if (!password) {
return res.status(400).json({ error: 'Password required' });
}
// Re-authenticate the current user with the provided password
const user = await authenticateUser(req.user!.email, password);
if (!user) {
return res.status(401).json({ error: 'Invalid password', verified: false });
}
res.json({ verified: true });
} catch (error) {
console.error('Password verification error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;

View File

@@ -0,0 +1,138 @@
import { useState, useEffect, useRef } from 'react';
import { api } from '../lib/api';
import { Shield, X, Loader2 } from 'lucide-react';
interface PasswordConfirmModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
description: string;
}
export function PasswordConfirmModal({
isOpen,
onClose,
onConfirm,
title,
description,
}: PasswordConfirmModalProps) {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen) {
setPassword('');
setError('');
// Focus the input when modal opens
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [isOpen]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!password.trim()) {
setError('Password is required');
return;
}
setLoading(true);
setError('');
try {
const result = await api.verifyPassword(password);
if (result.verified) {
onConfirm();
onClose();
} else {
setError('Invalid password');
}
} catch (err: any) {
setError(err.message || 'Verification failed');
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black bg-opacity-50"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<div className="flex items-center gap-3">
<div className="p-2 bg-amber-100 rounded-lg">
<Shield className="w-5 h-5 text-amber-600" />
</div>
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
</div>
<button
onClick={onClose}
className="p-1 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
{/* Body */}
<form onSubmit={handleSubmit}>
<div className="px-6 py-4">
<p className="text-gray-600 mb-4">{description}</p>
<div className="space-y-2">
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700"
>
Enter your password to continue
</label>
<input
ref={inputRef}
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
placeholder="Password"
disabled={loading}
/>
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
</div>
</div>
{/* Footer */}
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg">
<button
type="button"
onClick={onClose}
disabled={loading}
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
Confirm
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -84,6 +84,13 @@ class ApiClient {
});
}
async verifyPassword(password: string) {
return this.request<{ verified: boolean; error?: string }>('/api/auth/verify-password', {
method: 'POST',
body: JSON.stringify({ password }),
});
}
async getMe() {
return this.request<{ user: any }>('/api/auth/me');
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { api } from '../lib/api';
import { Layout } from '../components/Layout';
import { PasswordConfirmModal } from '../components/PasswordConfirmModal';
import {
ListChecks,
Clock,
@@ -149,6 +150,10 @@ export default function TasksDashboard() {
const [workerReady, setWorkerReady] = useState(0);
const [scalingWorkers, setScalingWorkers] = useState(false);
// Password confirmation for scaling
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [pendingScaleDelta, setPendingScaleDelta] = useState(0);
// Filters
const [roleFilter, setRoleFilter] = useState<string>('');
const [statusFilter, setStatusFilter] = useState<string>('');
@@ -185,10 +190,19 @@ export default function TasksDashboard() {
}
};
const scaleWorkers = async (delta: number) => {
// Request to scale workers - shows confirmation modal first
const requestScaleWorkers = (delta: number) => {
const newReplicas = Math.max(0, Math.min(50, workerReplicas + delta));
if (newReplicas === workerReplicas) return;
setPendingScaleDelta(delta);
setShowConfirmModal(true);
};
// Actually scale workers after password confirmation
const executeScaleWorkers = async () => {
const newReplicas = Math.max(0, Math.min(50, workerReplicas + pendingScaleDelta));
setScalingWorkers(true);
try {
const res = await api.scaleK8sWorkers(newReplicas);
@@ -201,6 +215,7 @@ export default function TasksDashboard() {
setError(err.message || 'Failed to scale workers');
} finally {
setScalingWorkers(false);
setPendingScaleDelta(0);
}
};
@@ -256,46 +271,47 @@ export default function TasksDashboard() {
return (
<Layout>
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<ListChecks className="w-7 h-7 text-emerald-600" />
Task Queue
</h1>
<p className="text-gray-500 mt-1">
{totalActive} active, {totalPending} pending tasks
</p>
</div>
{/* Sticky Header */}
<div className="sticky top-0 z-10 bg-white pb-4 -mx-6 px-6 pt-2 border-b border-gray-200 shadow-sm">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<ListChecks className="w-7 h-7 text-emerald-600" />
Task Queue
</h1>
<p className="text-gray-500 mt-1">
{totalActive} active, {totalPending} pending tasks
</p>
</div>
<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>
)}
<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={() => requestScaleWorkers(-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={() => requestScaleWorkers(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
onClick={togglePool}
disabled={poolLoading}
@@ -318,6 +334,7 @@ export default function TasksDashboard() {
)}
</button>
<span className="text-sm text-gray-400">Auto-refreshes every 15s</span>
</div>
</div>
</div>
@@ -576,6 +593,18 @@ export default function TasksDashboard() {
</div>
</div>
</div>
{/* Password Confirmation Modal for Worker Scaling */}
<PasswordConfirmModal
isOpen={showConfirmModal}
onClose={() => {
setShowConfirmModal(false);
setPendingScaleDelta(0);
}}
onConfirm={executeScaleWorkers}
title="Confirm Worker Scaling"
description={`You are about to ${pendingScaleDelta > 0 ? 'add' : 'remove'} ${Math.abs(pendingScaleDelta)} workers (${workerReplicas}${Math.max(0, Math.min(50, workerReplicas + pendingScaleDelta))}). This action affects production infrastructure.`}
/>
</Layout>
);
}

36
k8s/scraper-rbac.yaml Normal file
View File

@@ -0,0 +1,36 @@
# RBAC configuration for scraper pod to control worker scaling
# Allows the scraper to read and scale the scraper-worker deployment
apiVersion: v1
kind: ServiceAccount
metadata:
name: scraper-sa
namespace: dispensary-scraper
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: worker-scaler
namespace: dispensary-scraper
rules:
# Allow reading deployment status
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list"]
# Allow scaling deployments (read/write the scale subresource)
- apiGroups: ["apps"]
resources: ["deployments/scale"]
verbs: ["get", "patch", "update"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: scraper-worker-scaler
namespace: dispensary-scraper
subjects:
- kind: ServiceAccount
name: scraper-sa
namespace: dispensary-scraper
roleRef:
kind: Role
name: worker-scaler
apiGroup: rbac.authorization.k8s.io

View File

@@ -25,6 +25,7 @@ spec:
labels:
app: scraper
spec:
serviceAccountName: scraper-sa
imagePullSecrets:
- name: regcred
containers: