Merge pull request 'feat/ci-auto-merge' (#12) from feat/ci-auto-merge into master
Reviewed-on: https://code.cannabrands.app/Creationshop/dispensary-scraper/pulls/12
This commit is contained in:
@@ -45,6 +45,31 @@ steps:
|
|||||||
when:
|
when:
|
||||||
event: pull_request
|
event: pull_request
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# AUTO-MERGE: Merge PR after all checks pass
|
||||||
|
# ===========================================
|
||||||
|
auto-merge:
|
||||||
|
image: alpine:latest
|
||||||
|
environment:
|
||||||
|
GITEA_TOKEN:
|
||||||
|
from_secret: gitea_token
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache curl
|
||||||
|
- |
|
||||||
|
echo "Merging PR #${CI_COMMIT_PULL_REQUEST}..."
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"Do":"merge"}' \
|
||||||
|
"https://code.cannabrands.app/api/v1/repos/Creationshop/dispensary-scraper/pulls/${CI_COMMIT_PULL_REQUEST}/merge"
|
||||||
|
depends_on:
|
||||||
|
- typecheck-backend
|
||||||
|
- typecheck-cannaiq
|
||||||
|
- typecheck-findadispo
|
||||||
|
- typecheck-findagram
|
||||||
|
when:
|
||||||
|
event: pull_request
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# MASTER DEPLOY: Parallel Docker builds
|
# MASTER DEPLOY: Parallel Docker builds
|
||||||
# ===========================================
|
# ===========================================
|
||||||
@@ -64,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
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ RUN npm ci --omit=dev
|
|||||||
|
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
# Copy migrations for auto-migrate on startup
|
||||||
|
COPY migrations ./migrations
|
||||||
|
|
||||||
# Create local images directory for when MinIO is not configured
|
# Create local images directory for when MinIO is not configured
|
||||||
RUN mkdir -p /app/public/images/products
|
RUN mkdir -p /app/public/images/products
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ router.get('/brands', async (req: Request, res: Response) => {
|
|||||||
array_agg(DISTINCT d.state) FILTER (WHERE d.state IS NOT NULL) as states,
|
array_agg(DISTINCT d.state) FILTER (WHERE d.state IS NOT NULL) as states,
|
||||||
COUNT(DISTINCT d.id) as store_count,
|
COUNT(DISTINCT d.id) as store_count,
|
||||||
COUNT(DISTINCT sp.id) as sku_count,
|
COUNT(DISTINCT sp.id) as sku_count,
|
||||||
ROUND(AVG(sp.price_rec)::numeric, 2) FILTER (WHERE sp.price_rec > 0) as avg_price_rec,
|
ROUND(AVG(sp.price_rec) FILTER (WHERE sp.price_rec > 0)::numeric, 2) as avg_price_rec,
|
||||||
ROUND(AVG(sp.price_med)::numeric, 2) FILTER (WHERE sp.price_med > 0) as avg_price_med
|
ROUND(AVG(sp.price_med) FILTER (WHERE sp.price_med > 0)::numeric, 2) as avg_price_med
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN dispensaries d ON sp.dispensary_id = d.id
|
JOIN dispensaries d ON sp.dispensary_id = d.id
|
||||||
WHERE sp.brand_name_raw IS NOT NULL AND sp.brand_name_raw != ''
|
WHERE sp.brand_name_raw IS NOT NULL AND sp.brand_name_raw != ''
|
||||||
@@ -154,10 +154,9 @@ router.get('/pricing', async (req: Request, res: Response) => {
|
|||||||
SELECT
|
SELECT
|
||||||
sp.category_raw as category,
|
sp.category_raw as category,
|
||||||
ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price,
|
ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price,
|
||||||
MIN(sp.price_rec) FILTER (WHERE sp.price_rec > 0) as min_price,
|
MIN(sp.price_rec) as min_price,
|
||||||
MAX(sp.price_rec) as max_price,
|
MAX(sp.price_rec) as max_price,
|
||||||
ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec)::numeric, 2)
|
ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec)::numeric, 2) as median_price,
|
||||||
FILTER (WHERE sp.price_rec > 0) as median_price,
|
|
||||||
COUNT(*) as product_count
|
COUNT(*) as product_count
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
WHERE sp.category_raw IS NOT NULL AND sp.price_rec > 0
|
WHERE sp.category_raw IS NOT NULL AND sp.price_rec > 0
|
||||||
@@ -169,7 +168,7 @@ router.get('/pricing', async (req: Request, res: Response) => {
|
|||||||
SELECT
|
SELECT
|
||||||
d.state,
|
d.state,
|
||||||
ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price,
|
ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price,
|
||||||
MIN(sp.price_rec) FILTER (WHERE sp.price_rec > 0) as min_price,
|
MIN(sp.price_rec) as min_price,
|
||||||
MAX(sp.price_rec) as max_price,
|
MAX(sp.price_rec) as max_price,
|
||||||
COUNT(DISTINCT sp.id) as product_count
|
COUNT(DISTINCT sp.id) as product_count
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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'";
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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[]> {
|
||||||
const result = await pool.query(
|
// Return empty metrics if worker_tasks table doesn't exist
|
||||||
`SELECT * FROM v_worker_capacity`
|
if (!await tableExists('worker_tasks')) {
|
||||||
);
|
return [];
|
||||||
return result.rows as CapacityMetrics[];
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT * FROM v_worker_capacity`
|
||||||
|
);
|
||||||
|
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> {
|
||||||
const result = await pool.query(
|
// Return null if worker_tasks table doesn't exist
|
||||||
`SELECT * FROM v_worker_capacity WHERE role = $1`,
|
if (!await tableExists('worker_tasks')) {
|
||||||
[role]
|
return null;
|
||||||
);
|
}
|
||||||
return (result.rows[0] as CapacityMetrics) || null;
|
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT * FROM v_worker_capacity WHERE role = $1`,
|
||||||
|
[role]
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user