fix: Build args format for version info + schema-tolerant routes

CI/CD:
- Fix build_args format in woodpecker CI (comma-separated, not YAML list)
- This fixes "unknown" SHA/version showing on remote deployments

Backend schema-tolerant fixes (graceful fallbacks when tables missing):
- users.ts: Check which columns exist before querying
- worker-registry.ts: Return empty result if table doesn't exist
- task-service.ts: Add tableExists() helper, handle missing tables/views
- proxies.ts: Return totalProxies in test-all response

Frontend fixes:
- Proxies: Use total from response for accurate progress display
- SEO PagesTab: Dim Generate button when no AI provider active

🤖 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-10 09:53:21 -07:00
parent 9647f94f89
commit 249d3c1b7f
9 changed files with 161 additions and 42 deletions

View File

@@ -89,11 +89,7 @@ steps:
from_secret: registry_password from_secret: registry_password
platforms: linux/amd64 platforms: linux/amd64
provenance: false provenance: false
build_args: build_args: APP_BUILD_VERSION=${CI_COMMIT_SHA:0:8},APP_GIT_SHA=${CI_COMMIT_SHA},APP_BUILD_TIME=${CI_PIPELINE_CREATED},CONTAINER_IMAGE_TAG=${CI_COMMIT_SHA:0:8}
- APP_BUILD_VERSION=${CI_COMMIT_SHA}
- APP_GIT_SHA=${CI_COMMIT_SHA}
- APP_BUILD_TIME=${CI_PIPELINE_CREATED}
- CONTAINER_IMAGE_TAG=${CI_COMMIT_SHA:0:8}
depends_on: [] depends_on: []
when: when:
branch: master branch: master

View File

@@ -183,8 +183,8 @@ router.post('/test-all', requireRole('superadmin', 'admin'), async (req, res) =>
return res.status(400).json({ error: 'Concurrency must be between 1 and 50' }); return res.status(400).json({ error: 'Concurrency must be between 1 and 50' });
} }
const jobId = await createProxyTestJob(mode, concurrency); const { jobId, totalProxies } = await createProxyTestJob(mode, concurrency);
res.json({ jobId, mode, concurrency, message: `Proxy test job started (mode: ${mode}, concurrency: ${concurrency})` }); res.json({ jobId, total: totalProxies, mode, concurrency, message: `Proxy test job started (mode: ${mode}, concurrency: ${concurrency})` });
} catch (error: any) { } catch (error: any) {
console.error('Error starting proxy test job:', error); console.error('Error starting proxy test job:', error);
res.status(500).json({ error: error.message || 'Failed to start proxy test job' }); res.status(500).json({ error: error.message || 'Failed to start proxy test job' });
@@ -195,8 +195,8 @@ router.post('/test-all', requireRole('superadmin', 'admin'), async (req, res) =>
router.post('/test-failed', requireRole('superadmin', 'admin'), async (req, res) => { router.post('/test-failed', requireRole('superadmin', 'admin'), async (req, res) => {
try { try {
const concurrency = parseInt(req.query.concurrency as string) || 10; const concurrency = parseInt(req.query.concurrency as string) || 10;
const jobId = await createProxyTestJob('failed', concurrency); const { jobId, totalProxies } = await createProxyTestJob('failed', concurrency);
res.json({ jobId, mode: 'failed', concurrency, message: 'Retesting failed proxies...' }); res.json({ jobId, total: totalProxies, mode: 'failed', concurrency, message: 'Retesting failed proxies...' });
} catch (error: any) { } catch (error: any) {
console.error('Error starting failed proxy test:', error); console.error('Error starting failed proxy test:', error);
res.status(500).json({ error: error.message || 'Failed to start proxy test job' }); res.status(500).json({ error: error.message || 'Failed to start proxy test job' });

View File

@@ -14,23 +14,36 @@ router.get('/', async (req: AuthRequest, res) => {
try { try {
const { search, domain } = req.query; const { search, domain } = req.query;
let query = ` // Check which columns exist (schema-tolerant)
SELECT id, email, role, first_name, last_name, phone, domain, created_at, updated_at const columnsResult = await pool.query(`
FROM users SELECT column_name FROM information_schema.columns
WHERE 1=1 WHERE table_name = 'users' AND column_name IN ('first_name', 'last_name', 'phone', 'domain')
`; `);
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
// Build column list based on what exists
const selectCols = ['id', 'email', 'role', 'created_at', 'updated_at'];
if (existingColumns.has('first_name')) selectCols.push('first_name');
if (existingColumns.has('last_name')) selectCols.push('last_name');
if (existingColumns.has('phone')) selectCols.push('phone');
if (existingColumns.has('domain')) selectCols.push('domain');
let query = `SELECT ${selectCols.join(', ')} FROM users WHERE 1=1`;
const params: any[] = []; const params: any[] = [];
let paramIndex = 1; let paramIndex = 1;
// Search by email, first_name, or last_name // Search by email (and optionally first_name, last_name if they exist)
if (search && typeof search === 'string') { if (search && typeof search === 'string') {
query += ` AND (email ILIKE $${paramIndex} OR first_name ILIKE $${paramIndex} OR last_name ILIKE $${paramIndex})`; const searchClauses = ['email ILIKE $' + paramIndex];
if (existingColumns.has('first_name')) searchClauses.push('first_name ILIKE $' + paramIndex);
if (existingColumns.has('last_name')) searchClauses.push('last_name ILIKE $' + paramIndex);
query += ` AND (${searchClauses.join(' OR ')})`;
params.push(`%${search}%`); params.push(`%${search}%`);
paramIndex++; paramIndex++;
} }
// Filter by domain // Filter by domain (if column exists)
if (domain && typeof domain === 'string') { if (domain && typeof domain === 'string' && existingColumns.has('domain')) {
query += ` AND domain = $${paramIndex}`; query += ` AND domain = $${paramIndex}`;
params.push(domain); params.push(domain);
paramIndex++; paramIndex++;
@@ -50,8 +63,22 @@ router.get('/', async (req: AuthRequest, res) => {
router.get('/:id', async (req: AuthRequest, res) => { router.get('/:id', async (req: AuthRequest, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
// Check which columns exist (schema-tolerant)
const columnsResult = await pool.query(`
SELECT column_name FROM information_schema.columns
WHERE table_name = 'users' AND column_name IN ('first_name', 'last_name', 'phone', 'domain')
`);
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
const selectCols = ['id', 'email', 'role', 'created_at', 'updated_at'];
if (existingColumns.has('first_name')) selectCols.push('first_name');
if (existingColumns.has('last_name')) selectCols.push('last_name');
if (existingColumns.has('phone')) selectCols.push('phone');
if (existingColumns.has('domain')) selectCols.push('domain');
const result = await pool.query(` const result = await pool.query(`
SELECT id, email, role, first_name, last_name, phone, domain, created_at, updated_at SELECT ${selectCols.join(', ')}
FROM users FROM users
WHERE id = $1 WHERE id = $1
`, [id]); `, [id]);

View File

@@ -273,6 +273,29 @@ router.post('/deregister', async (req: Request, res: Response) => {
*/ */
router.get('/workers', async (req: Request, res: Response) => { router.get('/workers', async (req: Request, res: Response) => {
try { try {
// Check if worker_registry table exists
const tableCheck = await pool.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'worker_registry'
) as exists
`);
if (!tableCheck.rows[0].exists) {
// Return empty result if table doesn't exist yet
return res.json({
success: true,
workers: [],
summary: {
active_count: 0,
idle_count: 0,
offline_count: 0,
total_count: 0,
active_roles: 0
}
});
}
const { status, role, include_terminated = 'false' } = req.query; const { status, role, include_terminated = 'false' } = req.query;
let whereClause = include_terminated === 'true' ? 'WHERE 1=1' : "WHERE status != 'terminated'"; let whereClause = include_terminated === 'true' ? 'WHERE 1=1' : "WHERE status != 'terminated'";

View File

@@ -39,7 +39,12 @@ export async function cleanupOrphanedJobs(): Promise<void> {
export type ProxyTestMode = 'all' | 'failed' | 'inactive'; export type ProxyTestMode = 'all' | 'failed' | 'inactive';
export async function createProxyTestJob(mode: ProxyTestMode = 'all', concurrency: number = DEFAULT_CONCURRENCY): Promise<number> { export interface CreateJobResult {
jobId: number;
totalProxies: number;
}
export async function createProxyTestJob(mode: ProxyTestMode = 'all', concurrency: number = DEFAULT_CONCURRENCY): Promise<CreateJobResult> {
// Check for existing running jobs first // Check for existing running jobs first
const existingJob = await getActiveProxyTestJob(); const existingJob = await getActiveProxyTestJob();
if (existingJob) { if (existingJob) {
@@ -79,7 +84,7 @@ export async function createProxyTestJob(mode: ProxyTestMode = 'all', concurrenc
console.error(`❌ Proxy test job ${jobId} failed:`, err); console.error(`❌ Proxy test job ${jobId} failed:`, err);
}); });
return jobId; return { jobId, totalProxies };
} }
export async function getProxyTestJob(jobId: number): Promise<ProxyTestJob | null> { export async function getProxyTestJob(jobId: number): Promise<ProxyTestJob | null> {

View File

@@ -10,6 +10,17 @@
import { pool } from '../db/pool'; import { pool } from '../db/pool';
// Helper to check if a table exists
async function tableExists(tableName: string): Promise<boolean> {
const result = await pool.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = $1
) as exists
`, [tableName]);
return result.rows[0].exists;
}
export type TaskRole = export type TaskRole =
| 'store_discovery' | 'store_discovery'
| 'entry_point_discovery' | 'entry_point_discovery'
@@ -270,6 +281,11 @@ class TaskService {
* List tasks with filters * List tasks with filters
*/ */
async listTasks(filter: TaskFilter = {}): Promise<WorkerTask[]> { async listTasks(filter: TaskFilter = {}): Promise<WorkerTask[]> {
// Return empty list if table doesn't exist
if (!await tableExists('worker_tasks')) {
return [];
}
const conditions: string[] = []; const conditions: string[] = [];
const params: (string | number | string[])[] = []; const params: (string | number | string[])[] = [];
let paramIndex = 1; let paramIndex = 1;
@@ -323,21 +339,41 @@ class TaskService {
* Get capacity metrics for all roles * Get capacity metrics for all roles
*/ */
async getCapacityMetrics(): Promise<CapacityMetrics[]> { async getCapacityMetrics(): Promise<CapacityMetrics[]> {
// Return empty metrics if worker_tasks table doesn't exist
if (!await tableExists('worker_tasks')) {
return [];
}
try {
const result = await pool.query( const result = await pool.query(
`SELECT * FROM v_worker_capacity` `SELECT * FROM v_worker_capacity`
); );
return result.rows as CapacityMetrics[]; return result.rows as CapacityMetrics[];
} catch {
// View may not exist
return [];
}
} }
/** /**
* Get capacity metrics for a specific role * Get capacity metrics for a specific role
*/ */
async getRoleCapacity(role: TaskRole): Promise<CapacityMetrics | null> { async getRoleCapacity(role: TaskRole): Promise<CapacityMetrics | null> {
// Return null if worker_tasks table doesn't exist
if (!await tableExists('worker_tasks')) {
return null;
}
try {
const result = await pool.query( const result = await pool.query(
`SELECT * FROM v_worker_capacity WHERE role = $1`, `SELECT * FROM v_worker_capacity WHERE role = $1`,
[role] [role]
); );
return (result.rows[0] as CapacityMetrics) || null; return (result.rows[0] as CapacityMetrics) || null;
} catch {
// View may not exist
return null;
}
} }
/** /**
@@ -463,12 +499,6 @@ class TaskService {
* Get task counts by status for dashboard * Get task counts by status for dashboard
*/ */
async getTaskCounts(): Promise<Record<TaskStatus, number>> { async getTaskCounts(): Promise<Record<TaskStatus, number>> {
const result = await pool.query(
`SELECT status, COUNT(*) as count
FROM worker_tasks
GROUP BY status`
);
const counts: Record<TaskStatus, number> = { const counts: Record<TaskStatus, number> = {
pending: 0, pending: 0,
claimed: 0, claimed: 0,
@@ -478,6 +508,17 @@ class TaskService {
stale: 0, stale: 0,
}; };
// Return empty counts if table doesn't exist
if (!await tableExists('worker_tasks')) {
return counts;
}
const result = await pool.query(
`SELECT status, COUNT(*) as count
FROM worker_tasks
GROUP BY status`
);
for (const row of result.rows) { for (const row of result.rows) {
const typedRow = row as { status: TaskStatus; count: string }; const typedRow = row as { status: TaskStatus; count: string };
counts[typedRow.status] = parseInt(typedRow.count, 10); counts[typedRow.status] = parseInt(typedRow.count, 10);

View File

@@ -320,7 +320,7 @@ class ApiClient {
} }
async testAllProxies() { async testAllProxies() {
return this.request<{ jobId: number; message: string }>('/api/proxies/test-all', { return this.request<{ jobId: number; total: number; message: string }>('/api/proxies/test-all', {
method: 'POST', method: 'POST',
}); });
} }

View File

@@ -96,7 +96,8 @@ export function Proxies() {
try { try {
const response = await api.testAllProxies(); const response = await api.testAllProxies();
setNotification({ message: 'Proxy testing job started', type: 'success' }); setNotification({ message: 'Proxy testing job started', type: 'success' });
setActiveJob({ id: response.jobId, status: 'pending', tested_proxies: 0, total_proxies: proxies.length, passed_proxies: 0, failed_proxies: 0 }); // Use response.total if available, otherwise proxies.length, but immediately poll for accurate count
setActiveJob({ id: response.jobId, status: 'pending', tested_proxies: 0, total_proxies: response.total || proxies.length || 0, passed_proxies: 0, failed_proxies: 0 });
} catch (error: any) { } catch (error: any) {
setNotification({ message: 'Failed to start testing: ' + error.message, type: 'error' }); setNotification({ message: 'Failed to start testing: ' + error.message, type: 'error' });
} }

View File

@@ -7,7 +7,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { api } from '../../../lib/api'; import { api } from '../../../lib/api';
import { Building2, Tag, Globe, Target, FileText, RefreshCw, Sparkles, Loader2 } from 'lucide-react'; import { Building2, Tag, Globe, Target, FileText, RefreshCw, Sparkles, Loader2, AlertCircle } from 'lucide-react';
interface SeoPage { interface SeoPage {
id: number; id: number;
@@ -47,11 +47,31 @@ export function PagesTab() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [generatingId, setGeneratingId] = useState<number | null>(null); const [generatingId, setGeneratingId] = useState<number | null>(null);
const [hasActiveAiProvider, setHasActiveAiProvider] = useState<boolean | null>(null);
useEffect(() => { useEffect(() => {
loadPages(); loadPages();
checkAiProvider();
}, [typeFilter, search]); }, [typeFilter, search]);
async function checkAiProvider() {
try {
const data = await api.getSettings();
const settings = data.settings || [];
// Check if either Anthropic or OpenAI is configured with an API key AND enabled
const anthropicKey = settings.find((s: any) => s.key === 'anthropic_api_key')?.value;
const anthropicEnabled = settings.find((s: any) => s.key === 'anthropic_enabled')?.value === 'true';
const openaiKey = settings.find((s: any) => s.key === 'openai_api_key')?.value;
const openaiEnabled = settings.find((s: any) => s.key === 'openai_enabled')?.value === 'true';
const hasProvider = (anthropicKey && anthropicEnabled) || (openaiKey && openaiEnabled);
setHasActiveAiProvider(!!hasProvider);
} catch (error) {
console.error('Failed to check AI provider:', error);
setHasActiveAiProvider(false);
}
}
async function loadPages() { async function loadPages() {
setLoading(true); setLoading(true);
try { try {
@@ -188,12 +208,18 @@ export function PagesTab() {
<td className="px-3 sm:px-4 py-3"> <td className="px-3 sm:px-4 py-3">
<button <button
onClick={() => handleGenerate(page.id)} onClick={() => handleGenerate(page.id)}
disabled={generatingId === page.id} disabled={generatingId === page.id || hasActiveAiProvider === false}
className="flex items-center gap-1 px-2 sm:px-3 py-1.5 text-xs font-medium bg-purple-50 text-purple-700 rounded-lg hover:bg-purple-100 disabled:opacity-50" className={`flex items-center gap-1 px-2 sm:px-3 py-1.5 text-xs font-medium rounded-lg disabled:cursor-not-allowed ${
title="Generate content" hasActiveAiProvider === false
? 'bg-gray-100 text-gray-400'
: 'bg-purple-50 text-purple-700 hover:bg-purple-100 disabled:opacity-50'
}`}
title={hasActiveAiProvider === false ? 'No Active AI Provider' : 'Generate content'}
> >
{generatingId === page.id ? ( {generatingId === page.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" /> <Loader2 className="w-3.5 h-3.5 animate-spin" />
) : hasActiveAiProvider === false ? (
<AlertCircle className="w-3.5 h-3.5" />
) : ( ) : (
<Sparkles className="w-3.5 h-3.5" /> <Sparkles className="w-3.5 h-3.5" />
)} )}