mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-04 00:47:02 +01:00
867 lines
31 KiB
TypeScript
867 lines
31 KiB
TypeScript
import React, { useCallback, useEffect, useState } from 'react';
|
|
import {
|
|
Typography,
|
|
Box,
|
|
Grid,
|
|
CardContent,
|
|
Button,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableContainer,
|
|
TableHead,
|
|
TableRow,
|
|
Stack,
|
|
alpha,
|
|
CircularProgress,
|
|
Chip,
|
|
TextField,
|
|
IconButton,
|
|
useMediaQuery,
|
|
Tabs,
|
|
Tab,
|
|
List,
|
|
ListItem,
|
|
ListItemText,
|
|
Switch,
|
|
Divider,
|
|
Alert,
|
|
Select,
|
|
MenuItem,
|
|
FormControl,
|
|
} from '@mui/material';
|
|
import { PageShell, GlassCard, GlassPaper, EmptyState } from '../components/ui';
|
|
import { useToast } from '../hooks/useToast';
|
|
import EditIcon from '@mui/icons-material/Edit';
|
|
import EmailIcon from '@mui/icons-material/Email';
|
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
|
import SettingsIcon from '@mui/icons-material/Settings';
|
|
import PeopleIcon from '@mui/icons-material/People';
|
|
import WallpaperIcon from '@mui/icons-material/Wallpaper';
|
|
import TerminalIcon from '@mui/icons-material/Terminal';
|
|
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
|
import InfoIcon from '@mui/icons-material/Info';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import api from '../lib/api';
|
|
import { useTitle } from '../hooks/useTitle';
|
|
import { formatDate } from '../utils/date';
|
|
import BannerSettingsDialog from '../components/BannerSettingsDialog';
|
|
|
|
interface Stats {
|
|
total_users: number;
|
|
total_ratings: number;
|
|
total_replies: number;
|
|
open_tickets: number;
|
|
email_config: {
|
|
host: string;
|
|
port: number;
|
|
use_tls: boolean;
|
|
use_ssl: boolean;
|
|
skip_verify: boolean;
|
|
};
|
|
server_info: {
|
|
debug: boolean;
|
|
allowed_hosts: string[];
|
|
frontend_url: string;
|
|
media_root: string;
|
|
static_root: string;
|
|
};
|
|
}
|
|
|
|
interface AdminUser {
|
|
id: number;
|
|
username: string;
|
|
email: string;
|
|
is_active: boolean;
|
|
is_superuser: boolean;
|
|
date_joined: string;
|
|
last_login: string;
|
|
ips: string[];
|
|
}
|
|
|
|
interface BannerSettingSchema {
|
|
key: string;
|
|
label: string;
|
|
type: 'number' | 'slider' | 'text' | 'boolean';
|
|
min?: number;
|
|
max?: number;
|
|
step?: number;
|
|
description?: string;
|
|
}
|
|
|
|
interface Banner {
|
|
id: number;
|
|
name: string;
|
|
slug: string;
|
|
description: string;
|
|
is_active: boolean;
|
|
is_enabled: boolean;
|
|
settings: Record<string, unknown>;
|
|
schema: BannerSettingSchema[];
|
|
}
|
|
|
|
interface Job {
|
|
id: number;
|
|
name: string;
|
|
name_display: string;
|
|
status: 'pending' | 'running' | 'completed' | 'failed';
|
|
last_run: string | null;
|
|
next_run: string | null;
|
|
interval_hours: number;
|
|
last_output: string;
|
|
error_message: string;
|
|
}
|
|
|
|
interface SystemConfig {
|
|
site_name: string;
|
|
maintenance_mode: boolean;
|
|
allow_new_signups: boolean;
|
|
require_email_verification: boolean;
|
|
}
|
|
|
|
const AdminDashboard: React.FC = () => {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const isMobile = useMediaQuery('(max-width:900px)');
|
|
useTitle(t('admin.title'));
|
|
const [stats, setStats] = useState<Stats | null>(null);
|
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
|
const [banners, setBanners] = useState<Banner[]>([]);
|
|
const [jobs, setJobs] = useState<Job[]>([]);
|
|
const [config, setConfig] = useState<SystemConfig | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [search, setSearch] = useState('');
|
|
const [currentTab, setCurrentTab] = useState(0);
|
|
const [selectedBanner, setSelectedBanner] = useState<Banner | null>(null);
|
|
const [isSettingsDialogOpen, setIsSettingsDialogOpen] = useState(false);
|
|
const { notify } = useToast();
|
|
|
|
const fetchData = useCallback(async (silent = false) => {
|
|
if (!silent) setLoading(true);
|
|
try {
|
|
const [statsRes, usersRes, bannersRes, jobsRes, configRes] = await Promise.all([
|
|
api.get('admin-custom/stats/'),
|
|
api.get('admin-custom/users/'),
|
|
api.get('banners/'),
|
|
api.get('admin-custom/jobs/'),
|
|
api.get('admin-custom/config/'),
|
|
]);
|
|
|
|
setStats(statsRes.data);
|
|
setUsers(usersRes.data);
|
|
|
|
const bannersData = Array.isArray(bannersRes.data)
|
|
? bannersRes.data
|
|
: bannersRes.data.results || [];
|
|
setBanners(bannersData);
|
|
|
|
setJobs(jobsRes.data);
|
|
setConfig(configRes.data);
|
|
} catch (err) {
|
|
console.error('Data fetch error:', err);
|
|
}
|
|
|
|
if (!silent) setLoading(false);
|
|
}, []);
|
|
|
|
// Poll for job updates if any are running or pending
|
|
useEffect(() => {
|
|
const anyRunning = jobs.some((j) => j.status === 'running' || j.status === 'pending');
|
|
if (anyRunning && currentTab === 3) {
|
|
const interval = setInterval(() => fetchData(true), 3000);
|
|
return () => clearInterval(interval);
|
|
}
|
|
}, [jobs, currentTab, fetchData]);
|
|
|
|
const handleUpdateConfig = async (key: string, value: unknown) => {
|
|
try {
|
|
const res = await api.patch('admin-custom/config/', { [key]: value });
|
|
setConfig(res.data);
|
|
} catch {
|
|
notify({ message: 'Failed to update configuration', severity: 'error' });
|
|
}
|
|
};
|
|
|
|
const handleTriggerJob = async (id: number) => {
|
|
try {
|
|
await api.post(`admin-custom/${id}/trigger_job/`);
|
|
fetchData(true);
|
|
} catch {
|
|
notify({ message: 'Failed to trigger job', severity: 'error' });
|
|
}
|
|
};
|
|
|
|
const handleUpdateJobSchedule = async (id: number, interval: number, nextRun?: string) => {
|
|
try {
|
|
const data: Record<string, unknown> = { interval_hours: interval };
|
|
if (nextRun) data.next_run = nextRun;
|
|
await api.patch(`admin-custom/${id}/update_job_schedule/`, data);
|
|
fetchData(true);
|
|
} catch {
|
|
notify({ message: 'Failed to update schedule', severity: 'error' });
|
|
}
|
|
};
|
|
|
|
const handleActivateBanner = async (id: number) => {
|
|
try {
|
|
await api.post(`banners/${id}/activate/`);
|
|
fetchData();
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const handleToggleEnabled = async (id: number) => {
|
|
try {
|
|
await api.post(`banners/${id}/toggle_enabled/`);
|
|
fetchData();
|
|
} catch (err) {
|
|
const error = err as { response?: { data?: { error?: string } } };
|
|
notify({
|
|
message: error.response?.data?.error || 'Failed to toggle visibility',
|
|
severity: 'error',
|
|
});
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [fetchData]);
|
|
|
|
const handleSendTestEmail = async () => {
|
|
try {
|
|
await api.post('admin-custom/send_test_email/');
|
|
notify({ message: t('admin.testEmailSuccess'), severity: 'success' });
|
|
} catch (err) {
|
|
const error = err as { response?: { data?: { error?: string } } };
|
|
notify({
|
|
message: `Error: ${error.response?.data?.error || 'Failed to send'}`,
|
|
severity: 'error',
|
|
});
|
|
}
|
|
};
|
|
|
|
const filteredUsers = users.filter(
|
|
(u) =>
|
|
u.username.toLowerCase().includes(search.toLowerCase()) ||
|
|
u.email.toLowerCase().includes(search.toLowerCase()),
|
|
);
|
|
|
|
if (loading)
|
|
return (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}>
|
|
<CircularProgress />
|
|
</Box>
|
|
);
|
|
|
|
return (
|
|
<PageShell>
|
|
<Button
|
|
variant="outlined"
|
|
startIcon={<ArrowBackIcon />}
|
|
onClick={() => navigate(-1)}
|
|
sx={{ mb: 3, borderRadius: 2 }}
|
|
>
|
|
{t('common.back')}
|
|
</Button>
|
|
<Typography variant={isMobile ? 'h4' : 'h3'} sx={{ fontWeight: 'bold', mb: 4 }}>
|
|
{t('admin.title')}
|
|
</Typography>
|
|
|
|
<Tabs
|
|
value={currentTab}
|
|
onChange={(_, v) => setCurrentTab(v)}
|
|
sx={{ mb: 4, borderBottom: 1, borderColor: 'divider' }}
|
|
variant={isMobile ? 'scrollable' : 'standard'}
|
|
>
|
|
<Tab icon={<SettingsIcon />} label={t('admin.overview')} iconPosition="start" />
|
|
<Tab icon={<PeopleIcon />} label={t('admin.users')} iconPosition="start" />
|
|
<Tab icon={<WallpaperIcon />} label={t('admin.banners')} iconPosition="start" />
|
|
<Tab icon={<TerminalIcon />} label="Jobs & Workers" iconPosition="start" />
|
|
</Tabs>
|
|
|
|
{currentTab === 0 && (
|
|
<Box>
|
|
<Grid container spacing={isMobile ? 2 : 3} sx={{ mb: 6 }}>
|
|
<Grid size={{ xs: 6, md: 3 }}>
|
|
<GlassCard
|
|
intensity="subtle"
|
|
sx={{ bgcolor: (theme) => alpha(theme.palette.primary.main, 0.1) }}
|
|
>
|
|
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
|
<Typography variant="overline" sx={{ fontSize: '0.6rem' }}>
|
|
{t('admin.totalUsers')}
|
|
</Typography>
|
|
<Typography variant={isMobile ? 'h5' : 'h4'} sx={{ fontWeight: 'bold' }}>
|
|
{stats?.total_users}
|
|
</Typography>
|
|
</CardContent>
|
|
</GlassCard>
|
|
</Grid>
|
|
<Grid size={{ xs: 6, md: 3 }}>
|
|
<GlassCard
|
|
intensity="subtle"
|
|
sx={{ bgcolor: (theme) => alpha(theme.palette.error.main, 0.1) }}
|
|
>
|
|
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
|
<Typography variant="overline" sx={{ fontSize: '0.6rem' }}>
|
|
{t('admin.openTickets')}
|
|
</Typography>
|
|
<Typography
|
|
variant={isMobile ? 'h5' : 'h4'}
|
|
sx={{ fontWeight: 'bold', color: 'error.main' }}
|
|
>
|
|
{stats?.open_tickets}
|
|
</Typography>
|
|
</CardContent>
|
|
</GlassCard>
|
|
</Grid>
|
|
<Grid size={{ xs: 12, md: 6 }}>
|
|
<GlassCard intensity="subtle">
|
|
<CardContent sx={{ p: isMobile ? 2 : 3 }}>
|
|
<Stack
|
|
direction={isMobile ? 'column' : 'row'}
|
|
justifyContent="space-between"
|
|
alignItems={isMobile ? 'flex-start' : 'center'}
|
|
spacing={2}
|
|
>
|
|
<Box>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
|
|
{t('admin.config')}: SMTP
|
|
</Typography>
|
|
<Typography
|
|
variant="caption"
|
|
color="text.secondary"
|
|
sx={{ display: 'block' }}
|
|
>
|
|
{stats?.email_config.host}:{stats?.email_config.port} • TLS:{' '}
|
|
{String(stats?.email_config.use_tls)} • Insecure:{' '}
|
|
{String(stats?.email_config.skip_verify)}
|
|
</Typography>
|
|
</Box>
|
|
<Button
|
|
variant="outlined"
|
|
fullWidth={isMobile}
|
|
startIcon={<EmailIcon />}
|
|
onClick={handleSendTestEmail}
|
|
size="small"
|
|
>
|
|
{t('admin.sendTestEmail')}
|
|
</Button>
|
|
</Stack>
|
|
</CardContent>
|
|
</GlassCard>
|
|
</Grid>
|
|
</Grid>
|
|
|
|
<Grid container spacing={4}>
|
|
{/* Live Config */}
|
|
<Grid size={{ xs: 12, md: 6 }}>
|
|
<Typography variant="h6" sx={{ fontWeight: 'bold', mb: 2 }}>
|
|
System Configuration (Live)
|
|
</Typography>
|
|
<GlassPaper intensity="subtle" sx={{ p: 0, overflow: 'hidden' }}>
|
|
<List disablePadding>
|
|
{[
|
|
{ key: 'site_name', label: 'Site Name', type: 'text' },
|
|
{ key: 'maintenance_mode', label: 'Maintenance Mode', type: 'switch' },
|
|
{ key: 'allow_new_signups', label: 'Allow Signups', type: 'switch' },
|
|
{
|
|
key: 'require_email_verification',
|
|
label: 'Require Email Verify',
|
|
type: 'switch',
|
|
},
|
|
].map((item, idx) => (
|
|
<ListItem key={item.key} divider={idx < 3} sx={{ py: 2 }}>
|
|
<ListItemText
|
|
primary={item.label}
|
|
secondary={
|
|
item.type === 'text'
|
|
? (config as unknown as Record<string, string>)?.[item.key]
|
|
: null
|
|
}
|
|
/>
|
|
{item.type === 'switch' ? (
|
|
<Switch
|
|
checked={
|
|
(config as unknown as Record<string, boolean>)?.[item.key] || false
|
|
}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
|
handleUpdateConfig(item.key, e.target.checked)
|
|
}
|
|
/>
|
|
) : (
|
|
<Button
|
|
size="small"
|
|
onClick={() => {
|
|
const val = prompt(
|
|
`Enter new value for ${item.label}:`,
|
|
(config as unknown as Record<string, string>)?.[item.key],
|
|
);
|
|
if (val !== null) handleUpdateConfig(item.key, val);
|
|
}}
|
|
>
|
|
Edit
|
|
</Button>
|
|
)}
|
|
</ListItem>
|
|
))}
|
|
</List>
|
|
</GlassPaper>
|
|
</Grid>
|
|
|
|
{/* Environment Info */}
|
|
<Grid size={{ xs: 12, md: 6 }}>
|
|
<Typography variant="h6" sx={{ fontWeight: 'bold', mb: 2 }}>
|
|
Environment Info (.env)
|
|
</Typography>
|
|
<GlassPaper intensity="subtle" sx={{ p: 3 }}>
|
|
<Stack spacing={2.5}>
|
|
<Box>
|
|
<Typography
|
|
variant="caption"
|
|
color="text.secondary"
|
|
sx={{ fontWeight: 'bold', textTransform: 'uppercase' }}
|
|
>
|
|
API Status
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
|
DEBUG={String(stats?.server_info.debug)}
|
|
</Typography>
|
|
</Box>
|
|
<Box>
|
|
<Typography
|
|
variant="caption"
|
|
color="text.secondary"
|
|
sx={{ fontWeight: 'bold', textTransform: 'uppercase' }}
|
|
>
|
|
Frontend URL
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
|
{stats?.server_info.frontend_url}
|
|
</Typography>
|
|
</Box>
|
|
<Box>
|
|
<Typography
|
|
variant="caption"
|
|
color="text.secondary"
|
|
sx={{ fontWeight: 'bold', textTransform: 'uppercase' }}
|
|
>
|
|
Allowed Hosts
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
|
{stats?.server_info.allowed_hosts.join(', ')}
|
|
</Typography>
|
|
</Box>
|
|
<Divider />
|
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start' }}>
|
|
<InfoIcon fontSize="small" color="primary" />
|
|
<Typography variant="caption" color="text.secondary">
|
|
These values are read from your server environment or .env file. To change
|
|
them, you must update the file and restart the backend container.
|
|
</Typography>
|
|
</Box>
|
|
</Stack>
|
|
</GlassPaper>
|
|
</Grid>
|
|
</Grid>
|
|
</Box>
|
|
)}
|
|
|
|
{currentTab === 1 && (
|
|
<Box sx={{ mb: 4 }}>
|
|
<Stack
|
|
direction={isMobile ? 'column' : 'row'}
|
|
justifyContent="space-between"
|
|
alignItems={isMobile ? 'flex-start' : 'center'}
|
|
sx={{ mb: 2 }}
|
|
spacing={2}
|
|
>
|
|
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>
|
|
{t('admin.users')}
|
|
</Typography>
|
|
<TextField
|
|
size="small"
|
|
placeholder="Search..."
|
|
fullWidth={isMobile}
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
sx={{ width: isMobile ? '100%' : 300 }}
|
|
/>
|
|
</Stack>
|
|
|
|
{isMobile ? (
|
|
<Stack spacing={2}>
|
|
{filteredUsers.map((user) => (
|
|
<GlassCard key={user.id} intensity="subtle" sx={{ borderRadius: 3 }}>
|
|
<CardContent sx={{ p: 2 }}>
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>
|
|
{user.username}
|
|
</Typography>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => navigate(`/admin-panel/user/${user.id}`)}
|
|
>
|
|
<EditIcon fontSize="small" />
|
|
</IconButton>
|
|
</Box>
|
|
<Typography variant="caption" color="text.secondary" display="block">
|
|
{user.email}
|
|
</Typography>
|
|
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
|
<Chip
|
|
label={user.is_active ? 'Active' : 'Inactive'}
|
|
color={user.is_active ? 'success' : 'default'}
|
|
size="small"
|
|
sx={{ height: 20, fontSize: '0.65rem' }}
|
|
/>
|
|
{user.is_superuser && (
|
|
<Chip
|
|
label="Admin"
|
|
color="secondary"
|
|
size="small"
|
|
sx={{ height: 20, fontSize: '0.65rem' }}
|
|
/>
|
|
)}
|
|
</Stack>
|
|
</CardContent>
|
|
</GlassCard>
|
|
))}
|
|
</Stack>
|
|
) : (
|
|
<TableContainer component={GlassPaper} sx={{ borderRadius: 3 }}>
|
|
<Table>
|
|
<TableHead sx={{ bgcolor: 'action.hover' }}>
|
|
<TableRow>
|
|
<TableCell sx={{ fontWeight: 'bold' }}>ID</TableCell>
|
|
<TableCell sx={{ fontWeight: 'bold' }}>User</TableCell>
|
|
<TableCell sx={{ fontWeight: 'bold' }}>Email</TableCell>
|
|
<TableCell sx={{ fontWeight: 'bold' }}>Status</TableCell>
|
|
<TableCell sx={{ fontWeight: 'bold' }}>Joined</TableCell>
|
|
<TableCell sx={{ fontWeight: 'bold' }}>IP History</TableCell>
|
|
<TableCell sx={{ fontWeight: 'bold' }} align="right">
|
|
Actions
|
|
</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{filteredUsers.map((user) => (
|
|
<TableRow key={user.id} hover>
|
|
<TableCell>{user.id}</TableCell>
|
|
<TableCell>
|
|
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
|
|
{user.username}
|
|
</Typography>
|
|
{user.is_superuser && (
|
|
<Chip
|
|
label="Superuser"
|
|
size="small"
|
|
color="secondary"
|
|
sx={{ height: 16, fontSize: '0.6rem' }}
|
|
/>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>{user.email}</TableCell>
|
|
<TableCell>
|
|
<Chip
|
|
label={user.is_active ? 'Active' : 'Inactive'}
|
|
color={user.is_active ? 'success' : 'default'}
|
|
size="small"
|
|
/>
|
|
</TableCell>
|
|
<TableCell>{formatDate(user.date_joined)}</TableCell>
|
|
<TableCell>
|
|
<Stack direction="row" spacing={0.5} flexWrap="wrap">
|
|
{user.ips.slice(0, 3).map((ip) => (
|
|
<Chip
|
|
key={ip}
|
|
label={ip}
|
|
size="small"
|
|
variant="outlined"
|
|
sx={{ fontSize: '0.7rem' }}
|
|
/>
|
|
))}
|
|
{user.ips.length > 3 && (
|
|
<Typography variant="caption">+{user.ips.length - 3}</Typography>
|
|
)}
|
|
</Stack>
|
|
</TableCell>
|
|
<TableCell align="right">
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => navigate(`/admin-panel/user/${user.id}`)}
|
|
>
|
|
<EditIcon fontSize="small" />
|
|
</IconButton>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
)}
|
|
</Box>
|
|
)}
|
|
|
|
{currentTab === 2 && (
|
|
<Box>
|
|
<Typography variant="h5" sx={{ fontWeight: 'bold', mb: 3 }}>
|
|
{t('admin.banners')} ({banners.length})
|
|
</Typography>
|
|
|
|
{banners.length === 0 ? (
|
|
<EmptyState
|
|
title="No banner models found"
|
|
subtitle="No banners registered in the database."
|
|
action={
|
|
<Button variant="outlined" onClick={() => fetchData()}>
|
|
Retry Loading
|
|
</Button>
|
|
}
|
|
/>
|
|
) : (
|
|
<Stack spacing={3}>
|
|
{banners.map((banner) => (
|
|
<GlassCard
|
|
key={banner.id}
|
|
sx={{ borderRadius: 3, border: '1px solid', borderColor: 'divider' }}
|
|
>
|
|
<CardContent>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
mb: 2,
|
|
}}
|
|
>
|
|
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
|
{banner.name} (Slug: {banner.slug})
|
|
</Typography>
|
|
<Stack direction="row" spacing={1}>
|
|
{!banner.is_enabled && (
|
|
<Chip
|
|
label="Hidden from Users"
|
|
color="warning"
|
|
size="small"
|
|
variant="outlined"
|
|
/>
|
|
)}
|
|
{banner.is_active && (
|
|
<Chip label="Global Default" color="success" size="small" />
|
|
)}
|
|
</Stack>
|
|
</Box>
|
|
|
|
<Typography variant="body2" sx={{ mb: 2 }}>
|
|
{banner.description}
|
|
</Typography>
|
|
|
|
<Box sx={{ p: 2, bgcolor: 'action.hover', borderRadius: 2, mb: 2 }}>
|
|
<pre style={{ margin: 0, fontSize: '0.8rem' }}>
|
|
{JSON.stringify(banner.settings, null, 2)}
|
|
</pre>
|
|
</Box>
|
|
|
|
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
|
|
<Button
|
|
variant="outlined"
|
|
size="small"
|
|
onClick={() => {
|
|
setSelectedBanner(banner);
|
|
setIsSettingsDialogOpen(true);
|
|
}}
|
|
>
|
|
Edit Settings
|
|
</Button>
|
|
<Button
|
|
variant="outlined"
|
|
size="small"
|
|
color={banner.is_enabled ? 'warning' : 'success'}
|
|
onClick={() => handleToggleEnabled(banner.id)}
|
|
disabled={banner.is_active}
|
|
>
|
|
{banner.is_enabled ? 'Disable for Users' : 'Enable for Users'}
|
|
</Button>
|
|
{!banner.is_active && (
|
|
<Button
|
|
variant="contained"
|
|
size="small"
|
|
onClick={() => handleActivateBanner(banner.id)}
|
|
>
|
|
Set as Default
|
|
</Button>
|
|
)}
|
|
</Stack>
|
|
</CardContent>
|
|
</GlassCard>
|
|
))}
|
|
</Stack>
|
|
)}
|
|
</Box>
|
|
)}
|
|
|
|
{currentTab === 3 && (
|
|
<Box>
|
|
<Typography variant="h5" sx={{ fontWeight: 'bold', mb: 3 }}>
|
|
Background Worker Jobs
|
|
</Typography>
|
|
<Stack spacing={3}>
|
|
{jobs.map((job) => (
|
|
<GlassCard
|
|
key={job.id}
|
|
sx={{ borderRadius: 3, border: '1px solid', borderColor: 'divider' }}
|
|
>
|
|
<CardContent>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
mb: 2,
|
|
}}
|
|
>
|
|
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
|
{job.name_display}
|
|
</Typography>
|
|
<Chip
|
|
label={job.status === 'pending' ? 'QUEUED' : job.status.toUpperCase()}
|
|
size="small"
|
|
color={
|
|
job.status === 'completed'
|
|
? 'success'
|
|
: job.status === 'running'
|
|
? 'info'
|
|
: job.status === 'failed'
|
|
? 'error'
|
|
: 'default'
|
|
}
|
|
variant={job.status === 'running' ? 'filled' : 'outlined'}
|
|
/>
|
|
</Box>
|
|
|
|
<Grid container spacing={2} sx={{ mb: 2 }}>
|
|
<Grid size={{ xs: 12, md: 4 }}>
|
|
<Typography variant="caption" color="text.secondary" display="block">
|
|
Last Run
|
|
</Typography>
|
|
<Typography variant="body2">
|
|
{job.last_run ? formatDate(job.last_run) : 'Never'}
|
|
</Typography>
|
|
</Grid>
|
|
<Grid size={{ xs: 12, md: 4 }}>
|
|
<Typography variant="caption" color="text.secondary" display="block">
|
|
Next Run
|
|
</Typography>
|
|
<Typography variant="body2">
|
|
{job.next_run
|
|
? formatDate(job.next_run)
|
|
: job.interval_hours > 0
|
|
? 'Queued...'
|
|
: 'Disabled'}
|
|
</Typography>
|
|
</Grid>
|
|
<Grid size={{ xs: 12, md: 4 }}>
|
|
<Typography
|
|
variant="caption"
|
|
color="text.secondary"
|
|
display="block"
|
|
sx={{ mb: 0.5 }}
|
|
>
|
|
Schedule
|
|
</Typography>
|
|
<FormControl size="small" fullWidth>
|
|
<Select
|
|
value={job.interval_hours}
|
|
onChange={(e) => handleUpdateJobSchedule(job.id, Number(e.target.value))}
|
|
sx={{ height: 32, fontSize: '0.875rem' }}
|
|
>
|
|
<MenuItem value={0}>Disabled</MenuItem>
|
|
<MenuItem value={1}>Hourly</MenuItem>
|
|
<MenuItem value={6}>Every 6 Hours</MenuItem>
|
|
<MenuItem value={12}>Every 12 Hours</MenuItem>
|
|
<MenuItem value={24}>Daily</MenuItem>
|
|
<MenuItem value={168}>Weekly</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
</Grid>
|
|
</Grid>
|
|
|
|
{job.last_output && (
|
|
<Box sx={{ mb: 2 }}>
|
|
<Typography
|
|
variant="caption"
|
|
color="text.secondary"
|
|
display="block"
|
|
gutterBottom
|
|
>
|
|
Last Run Output
|
|
</Typography>
|
|
<Box
|
|
sx={{
|
|
p: 1.5,
|
|
bgcolor: '#1e1e1e',
|
|
color: '#d4d4d4',
|
|
borderRadius: 2,
|
|
maxHeight: 150,
|
|
overflowY: 'auto',
|
|
border: '1px solid #333',
|
|
}}
|
|
>
|
|
<pre
|
|
style={{
|
|
margin: 0,
|
|
fontSize: '0.75rem',
|
|
fontFamily: 'monospace',
|
|
whiteSpace: 'pre-wrap',
|
|
}}
|
|
>
|
|
{job.last_output}
|
|
</pre>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
|
|
{job.error_message && (
|
|
<Box sx={{ mb: 2 }}>
|
|
<Typography variant="caption" color="error" display="block" gutterBottom>
|
|
Last Error
|
|
</Typography>
|
|
<Alert severity="error" sx={{ py: 0 }}>
|
|
{job.error_message}
|
|
</Alert>
|
|
</Box>
|
|
)}
|
|
|
|
<Button
|
|
variant="contained"
|
|
startIcon={<PlayArrowIcon />}
|
|
disabled={job.status === 'running'}
|
|
onClick={() => handleTriggerJob(job.id)}
|
|
>
|
|
Trigger Now
|
|
</Button>
|
|
</CardContent>
|
|
</GlassCard>
|
|
))}
|
|
</Stack>
|
|
</Box>
|
|
)}
|
|
|
|
<BannerSettingsDialog
|
|
open={isSettingsDialogOpen}
|
|
onClose={() => {
|
|
setIsSettingsDialogOpen(false);
|
|
setSelectedBanner(null);
|
|
}}
|
|
banner={selectedBanner}
|
|
onSave={fetchData}
|
|
/>
|
|
</PageShell>
|
|
);
|
|
};
|
|
|
|
export default AdminDashboard;
|