Compare commits
2 Commits
fb1267c533
...
e79f9ea24c
| Author | SHA1 | Date | |
|---|---|---|---|
| e79f9ea24c | |||
| fdd3e03c7d |
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router';
|
||||
import { Network, Globe, AlertTriangle, Activity } from 'lucide-react';
|
||||
import { Network, Globe, AlertTriangle, Activity, Wrench } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useIPAMSummary, useIPAMNetworks } from '@/hooks/use-ipam';
|
||||
|
||||
@@ -13,7 +13,12 @@ export default function IPAMDashboardPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">{t('nav.ipam')}</h1>
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{t('nav.ipam')}</h1>
|
||||
<Link to="/ipam/tools" className="flex items-center gap-1 text-sm text-blue-400 hover:underline">
|
||||
<Wrench className="h-4 w-4" />{t('ipam.tools')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
|
||||
315
frontend/src/features/ipam/IPAMToolsPage.tsx
Normal file
315
frontend/src/features/ipam/IPAMToolsPage.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeft, AlertTriangle, Ghost, Unlink, Wand2, Search, Link2, Trash2, History } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
useIPAMNetworks, useIPAMSummary,
|
||||
useIPAMConflicts, useIPAMOrphans, useIPAMUnlinked,
|
||||
useCleanupOrphans, useLinkAllocations, useAutoAllocate, useNextAvailableIP, useIPHistoryLookup,
|
||||
} from '@/hooks/use-ipam';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function IPAMToolsPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button render={<Link to="/ipam" />} variant="ghost" size="sm"><ArrowLeft className="h-4 w-4" /></Button>
|
||||
<h1 className="text-2xl font-bold">{t('ipam.tools')}</h1>
|
||||
</div>
|
||||
|
||||
<SummaryCard />
|
||||
<AllocateCard />
|
||||
<ConflictsCard />
|
||||
<OrphansCard />
|
||||
<UnlinkedCard />
|
||||
<IPHistoryCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard() {
|
||||
const { t } = useTranslation();
|
||||
const { data } = useIPAMSummary();
|
||||
const s = data as Record<string, unknown> | undefined;
|
||||
const recent = (s?.recent_allocations as Array<{ ip: string; resource?: string; by?: string; at?: string }>) || [];
|
||||
const stat = (k: string) => Number(s?.[k] ?? 0);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">{t('ipam.summary')}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-6">
|
||||
{([
|
||||
['networks_count', t('ipam.networks_count')],
|
||||
['pools_count', t('ipam.pools')],
|
||||
['total_allocated', t('ipam.total_allocated')],
|
||||
['total_reserved', t('ipam.total_reserved')],
|
||||
['vm_count', 'VM'],
|
||||
['lxc_count', 'LXC'],
|
||||
] as const).map(([k, label]) => (
|
||||
<div key={k} className="rounded-lg border p-3 text-center">
|
||||
<p className="text-2xl font-bold">{stat(k)}</p>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{recent.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1 text-sm font-medium">{t('ipam.recent_activity')}</p>
|
||||
<div className="rounded-lg border divide-y text-sm">
|
||||
{recent.map((r, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-3 py-2">
|
||||
<span className="font-mono">{r.ip}</span>
|
||||
<span className="text-muted-foreground">{r.resource || ''}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{r.by} · {r.at ? new Date(r.at).toLocaleString() : ''}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AllocateCard() {
|
||||
const { t } = useTranslation();
|
||||
const { data: networks = [] } = useIPAMNetworks();
|
||||
const autoAllocate = useAutoAllocate();
|
||||
const nextAvail = useNextAvailableIP();
|
||||
const [networkId, setNetworkId] = useState('');
|
||||
const [resourceName, setResourceName] = useState('');
|
||||
const [hostname, setHostname] = useState('');
|
||||
|
||||
const handleNext = () => {
|
||||
if (!networkId) { toast.error(t('ipam.select_network')); return; }
|
||||
nextAvail.mutate({ networkId: Number(networkId) }, {
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
const handleAllocate = () => {
|
||||
if (!networkId) { toast.error(t('ipam.select_network')); return; }
|
||||
autoAllocate.mutate(
|
||||
{ network_id: Number(networkId), resource_name: resourceName || undefined, hostname: hostname || undefined },
|
||||
{
|
||||
onSuccess: (a) => { toast.success(`${t('ipam.allocated')}: ${a.ip_address}`); setResourceName(''); setHostname(''); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="flex items-center gap-2 text-lg"><Wand2 className="h-5 w-5" />{t('ipam.auto_allocate')}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<Label>{t('ipam.network')}</Label>
|
||||
<Select value={networkId} onValueChange={v => { if (v) setNetworkId(v); }}>
|
||||
<SelectTrigger className="mt-1"><SelectValue placeholder={t('ipam.select_network')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{networks.map(n => <SelectItem key={n.id} value={String(n.id)}>{n.name} ({n.network})</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('ipam.resource')}</Label>
|
||||
<Input value={resourceName} onChange={e => setResourceName(e.target.value)} className="mt-1" placeholder="web-01" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Hostname</Label>
|
||||
<Input value={hostname} onChange={e => setHostname(e.target.value)} className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={handleNext} disabled={nextAvail.isPending || !networkId}>
|
||||
<Search className="mr-1 h-4 w-4" />{t('ipam.next_available')}
|
||||
</Button>
|
||||
{nextAvail.data && <Badge variant="secondary" className="font-mono">{nextAvail.data.next_available_ip}</Badge>}
|
||||
<Button size="sm" onClick={handleAllocate} disabled={autoAllocate.isPending || !networkId} className="ml-auto">
|
||||
<Wand2 className="mr-1 h-4 w-4" />{t('ipam.allocate_next')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('ipam.pool_required_hint')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ConflictsCard() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useIPAMConflicts();
|
||||
const conflicts = data?.conflicts ?? [];
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<AlertTriangle className="h-5 w-5" />{t('ipam.conflicts')}
|
||||
{data && <Badge variant={data.count > 0 ? 'destructive' : 'secondary'}>{data.count}</Badge>}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? <p className="py-4 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
|
||||
: conflicts.length === 0 ? <p className="py-4 text-center text-sm text-muted-foreground">{t('ipam.no_conflicts')}</p>
|
||||
: (
|
||||
<div className="rounded-lg border divide-y text-sm">
|
||||
{conflicts.map((c, i) => (
|
||||
<div key={i} className="px-3 py-2 font-mono">{c.ip_address} — {JSON.stringify(c)}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function OrphansCard() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useIPAMOrphans();
|
||||
const cleanup = useCleanupOrphans();
|
||||
const orphans = data?.orphans ?? [];
|
||||
|
||||
const handleCleanup = () => {
|
||||
if (!confirm(t('ipam.cleanup_confirm'))) return;
|
||||
cleanup.mutate(undefined, {
|
||||
onSuccess: (r) => toast.success(`${t('ipam.released')}: ${r.released_count}`),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Ghost className="h-5 w-5" />{t('ipam.orphans')}
|
||||
{data && <Badge variant={data.count > 0 ? 'destructive' : 'secondary'}>{data.count}</Badge>}
|
||||
</CardTitle>
|
||||
{orphans.length > 0 && (
|
||||
<Button size="sm" variant="outline" onClick={handleCleanup} disabled={cleanup.isPending}>
|
||||
<Trash2 className="mr-1 h-4 w-4" />{t('ipam.cleanup_orphans')}
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? <p className="py-4 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
|
||||
: orphans.length === 0 ? <p className="py-4 text-center text-sm text-muted-foreground">{t('ipam.no_orphans')}</p>
|
||||
: (
|
||||
<Table>
|
||||
<TableHeader><TableRow>
|
||||
<TableHead>IP</TableHead><TableHead>{t('ipam.resource')}</TableHead>
|
||||
<TableHead>VMID</TableHead><TableHead>{t('common.status')}</TableHead>
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{orphans.map(o => (
|
||||
<TableRow key={o.id}>
|
||||
<TableCell className="font-mono">{o.ip_address}</TableCell>
|
||||
<TableCell>{o.resource_name || '—'}</TableCell>
|
||||
<TableCell>{o.proxmox_vmid ?? '—'}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{o.reason}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function UnlinkedCard() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useIPAMUnlinked();
|
||||
const link = useLinkAllocations();
|
||||
const unlinked = data?.unlinked ?? [];
|
||||
|
||||
const handleLink = () => {
|
||||
link.mutate(undefined, {
|
||||
onSuccess: (r) => toast.success(`${t('ipam.linked')}: ${r.linked?.length ?? 0}`),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Unlink className="h-5 w-5" />{t('ipam.unlinked')}
|
||||
{data && <Badge variant="secondary">{data.count}</Badge>}
|
||||
</CardTitle>
|
||||
{unlinked.length > 0 && (
|
||||
<Button size="sm" variant="outline" onClick={handleLink} disabled={link.isPending}>
|
||||
<Link2 className="mr-1 h-4 w-4" />{t('ipam.link_allocations')}
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? <p className="py-4 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
|
||||
: unlinked.length === 0 ? <p className="py-4 text-center text-sm text-muted-foreground">{t('ipam.no_unlinked')}</p>
|
||||
: (
|
||||
<Table>
|
||||
<TableHeader><TableRow>
|
||||
<TableHead>{t('ipam.resource')}</TableHead><TableHead>IP</TableHead>
|
||||
<TableHead>VMID</TableHead><TableHead>{t('nodes.title')}</TableHead>
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{unlinked.map((u, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>{u.resource_name || '—'}</TableCell>
|
||||
<TableCell className="font-mono">{u.ip_address}</TableCell>
|
||||
<TableCell>{u.suggested_vmid ?? '—'}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{u.suggested_server_name || '—'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function IPHistoryCard() {
|
||||
const { t } = useTranslation();
|
||||
const lookup = useIPHistoryLookup();
|
||||
const [ip, setIp] = useState('');
|
||||
const rows = (lookup.data as Array<Record<string, unknown>>) || [];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="flex items-center gap-2 text-lg"><History className="h-5 w-5" />{t('ipam.ip_history')}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input value={ip} onChange={e => setIp(e.target.value)} placeholder="10.0.0.10" className="max-w-xs font-mono"
|
||||
onKeyDown={e => { if (e.key === 'Enter' && ip) lookup.mutate(ip); }} />
|
||||
<Button size="sm" variant="outline" onClick={() => ip && lookup.mutate(ip)} disabled={lookup.isPending || !ip}>
|
||||
<Search className="mr-1 h-4 w-4" />{t('common.search')}
|
||||
</Button>
|
||||
</div>
|
||||
{lookup.isSuccess && (
|
||||
rows.length === 0 ? <p className="text-sm text-muted-foreground">{t('common.no_data')}</p>
|
||||
: (
|
||||
<div className="rounded-lg border divide-y text-sm">
|
||||
{rows.map((h, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-3 py-2">
|
||||
<Badge variant="outline" className="text-[10px]">{String(h.action ?? '')}</Badge>
|
||||
<span className="text-muted-foreground">{String(h.resource_name ?? '')}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{String(h.performed_by ?? '')} · {h.performed_at ? new Date(String(h.performed_at)).toLocaleString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,13 @@ import {
|
||||
useNotificationChannels, useUpdateSMTP, useUpdateTelegram,
|
||||
useAppVersion, useCheckUpdates,
|
||||
useUpdateRepository, useSetUpdateRepository, useUpdateStatus, usePerformUpdate, useResetUpdate,
|
||||
useTestSMTP, useTestTelegram,
|
||||
} from '@/hooks/use-settings';
|
||||
import {
|
||||
useNotificationPreferences, useUpdateNotificationPreferences,
|
||||
useChannelsStatus, useSendTestNotification, useVerifyTelegramChat, useTelegramBotInfo,
|
||||
} from '@/hooks/use-notifications';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { SSHKeysManager } from './SSHKeysManager';
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -188,6 +194,28 @@ function NotificationsTab() {
|
||||
}
|
||||
}, [channels?.telegram]);
|
||||
|
||||
const testSmtp = useTestSMTP();
|
||||
const testTg = useTestTelegram();
|
||||
const botInfo = useTelegramBotInfo();
|
||||
const [testEmail, setTestEmail] = useState('');
|
||||
|
||||
const handleTestSmtp = () => {
|
||||
if (!testEmail) { toast.error(t('settings.test_email_required')); return; }
|
||||
testSmtp.mutate(testEmail, {
|
||||
onSuccess: (r) => toast.success(r.message),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
const handleTestTg = () => {
|
||||
if (!tgChatId) { toast.error(t('settings.chat_id_required')); return; }
|
||||
testTg.mutate(tgChatId, {
|
||||
onSuccess: (r) => toast.success(r.message),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
const bot = botInfo.data?.bot_info as { username?: string; first_name?: string } | null | undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
@@ -201,20 +229,144 @@ function NotificationsTab() {
|
||||
<div><Label>From Email</Label><Input value={smtpFrom} onChange={e => setSmtpFrom(e.target.value)} className="mt-1" /></div>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => updateSmtp.mutate({ host: smtpHost, port: Number(smtpPort), username: smtpUser, password: smtpPass || undefined, from_email: smtpFrom })}>{t('common.save')}</Button>
|
||||
<div className="flex items-center gap-2 border-t pt-3">
|
||||
<Input value={testEmail} onChange={e => setTestEmail(e.target.value)} className="max-w-xs" placeholder={t('settings.test_email_placeholder')} />
|
||||
<Button size="sm" variant="outline" onClick={handleTestSmtp} disabled={testSmtp.isPending}>{t('settings.send_test')}</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-sm">Telegram</CardTitle></CardHeader>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
Telegram
|
||||
{bot?.username && <Badge variant="outline">@{bot.username}</Badge>}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div><Label>Bot Token</Label><Input value={tgToken} onChange={e => setTgToken(e.target.value)} className="mt-1" /></div>
|
||||
<div><Label>Chat ID</Label><Input value={tgChatId} onChange={e => setTgChatId(e.target.value)} className="mt-1" /></div>
|
||||
<Button size="sm" onClick={() => updateTg.mutate({ telegram_bot_token: tgToken })}>{t('common.save')}</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={() => updateTg.mutate({ telegram_bot_token: tgToken })}>{t('common.save')}</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleTestTg} disabled={testTg.isPending}>{t('settings.send_test')}</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<NotificationPreferencesCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ id, checked, onChange, label }: { id: string; checked: boolean; onChange: (v: boolean) => void; label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input id={id} type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} className="h-4 w-4" />
|
||||
<Label htmlFor={id} className="cursor-pointer">{label}</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationPreferencesCard() {
|
||||
const { t } = useTranslation();
|
||||
const { data: prefs } = useNotificationPreferences();
|
||||
const update = useUpdateNotificationPreferences();
|
||||
const sendTest = useSendTestNotification();
|
||||
const verifyTg = useVerifyTelegramChat();
|
||||
const { data: channelsStatus } = useChannelsStatus();
|
||||
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [emailEnabled, setEmailEnabled] = useState(false);
|
||||
const [emailCritical, setEmailCritical] = useState(false);
|
||||
const [tgEnabled, setTgEnabled] = useState(false);
|
||||
const [tgChatId, setTgChatId] = useState('');
|
||||
const [webhook, setWebhook] = useState('');
|
||||
const [quietStart, setQuietStart] = useState('');
|
||||
const [quietEnd, setQuietEnd] = useState('');
|
||||
const [testChannel, setTestChannel] = useState('all');
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefs) return;
|
||||
setEnabled(prefs.enabled);
|
||||
setEmailEnabled(prefs.email_enabled);
|
||||
setEmailCritical(prefs.email_critical_only);
|
||||
setTgEnabled(prefs.telegram_enabled);
|
||||
setTgChatId(prefs.telegram_chat_id ?? '');
|
||||
setWebhook(prefs.webhook_url ?? '');
|
||||
setQuietStart(prefs.quiet_hours_start ?? '');
|
||||
setQuietEnd(prefs.quiet_hours_end ?? '');
|
||||
}, [prefs]);
|
||||
|
||||
const handleSave = () => {
|
||||
update.mutate({
|
||||
enabled, email_enabled: emailEnabled, email_critical_only: emailCritical,
|
||||
telegram_enabled: tgEnabled, telegram_chat_id: tgChatId || undefined,
|
||||
webhook_url: webhook || undefined,
|
||||
quiet_hours_start: quietStart || undefined, quiet_hours_end: quietEnd || undefined,
|
||||
}, {
|
||||
onSuccess: () => toast.success(t('settings.prefs_saved')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
const handleVerify = () => {
|
||||
if (!tgChatId) { toast.error(t('settings.chat_id_required')); return; }
|
||||
verifyTg.mutate(tgChatId, {
|
||||
onSuccess: (r) => toast.success(r.message),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
const handleTest = () => {
|
||||
sendTest.mutate(testChannel, {
|
||||
onSuccess: () => toast.success(t('settings.test_sent')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-sm">{t('settings.my_notifications')}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Toggle id="np_enabled" checked={enabled} onChange={setEnabled} label={t('settings.notifications_enabled')} />
|
||||
<Toggle id="np_email" checked={emailEnabled} onChange={setEmailEnabled} label={t('settings.email_enabled')} />
|
||||
<Toggle id="np_email_crit" checked={emailCritical} onChange={setEmailCritical} label={t('settings.email_critical_only')} />
|
||||
<Toggle id="np_tg" checked={tgEnabled} onChange={setTgEnabled} label={t('settings.telegram_enabled')} />
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1"><Label>Telegram Chat ID</Label><Input value={tgChatId} onChange={e => setTgChatId(e.target.value)} className="mt-1" /></div>
|
||||
<Button size="sm" variant="outline" onClick={handleVerify} disabled={verifyTg.isPending}>{t('settings.verify')}</Button>
|
||||
</div>
|
||||
<div><Label>Webhook URL</Label><Input value={webhook} onChange={e => setWebhook(e.target.value)} className="mt-1" placeholder="https://..." /></div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><Label>{t('settings.quiet_start')}</Label><Input value={quietStart} onChange={e => setQuietStart(e.target.value)} className="mt-1" placeholder="22:00" /></div>
|
||||
<div><Label>{t('settings.quiet_end')}</Label><Input value={quietEnd} onChange={e => setQuietEnd(e.target.value)} className="mt-1" placeholder="08:00" /></div>
|
||||
</div>
|
||||
<Button size="sm" onClick={handleSave} disabled={update.isPending}>{t('common.save')}</Button>
|
||||
|
||||
<div className="flex items-center gap-2 border-t pt-3">
|
||||
<Select value={testChannel} onValueChange={v => { if (v) setTestChannel(v); }}>
|
||||
<SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('common.all')}</SelectItem>
|
||||
<SelectItem value="email">Email</SelectItem>
|
||||
<SelectItem value="telegram">Telegram</SelectItem>
|
||||
<SelectItem value="inapp">In-app</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" variant="outline" onClick={handleTest} disabled={sendTest.isPending}>{t('settings.send_test_notification')}</Button>
|
||||
{channelsStatus && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{Object.entries(channelsStatus).map(([k, v]) => {
|
||||
const conf = (v as { configured?: boolean })?.configured;
|
||||
return typeof conf === 'boolean' ? `${k}: ${conf ? '✓' : '✗'}` : null;
|
||||
}).filter(Boolean).join(' ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AboutTab() {
|
||||
const { t } = useTranslation();
|
||||
const { data: version } = useAppVersion();
|
||||
|
||||
@@ -129,3 +129,90 @@ export function useDeleteAllocation() {
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['ipam-allocations'] }),
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Tools / maintenance ====================
|
||||
|
||||
export interface IPAMConflict { ip_address: string; [k: string]: unknown }
|
||||
export interface IPAMOrphan {
|
||||
id: number; ip_address: string; resource_name?: string; resource_type?: string;
|
||||
proxmox_vmid?: number; proxmox_server_id?: number; reason: string;
|
||||
}
|
||||
export interface IPAMUnlinked {
|
||||
id: number | null; ip_address: string; resource_name?: string; resource_type?: string;
|
||||
can_link: boolean; suggested_vmid?: number; suggested_server_id?: number; suggested_server_name?: string | null;
|
||||
}
|
||||
|
||||
export function useIPAMConflicts() {
|
||||
return useQuery({
|
||||
queryKey: ipamKeys.conflicts,
|
||||
queryFn: () => apiClient.get<{ conflicts: IPAMConflict[]; count: number }>('/ipam/api/conflicts'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useIPAMOrphans() {
|
||||
return useQuery({
|
||||
queryKey: ['ipam-orphans'],
|
||||
queryFn: () => apiClient.get<{ orphans: IPAMOrphan[]; count: number }>('/ipam/api/orphans'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useIPAMUnlinked() {
|
||||
return useQuery({
|
||||
queryKey: ['ipam-unlinked'],
|
||||
queryFn: () => apiClient.get<{ unlinked: IPAMUnlinked[]; count: number; linkable_count: number }>('/ipam/api/unlinked'),
|
||||
});
|
||||
}
|
||||
|
||||
// Invalidate everything that orphan/link/auto operations can affect.
|
||||
function useInvalidateIPAM() {
|
||||
const qc = useQueryClient();
|
||||
return () => {
|
||||
qc.invalidateQueries({ queryKey: ['ipam-allocations'] });
|
||||
qc.invalidateQueries({ queryKey: ['ipam-orphans'] });
|
||||
qc.invalidateQueries({ queryKey: ['ipam-unlinked'] });
|
||||
qc.invalidateQueries({ queryKey: ipamKeys.summary });
|
||||
qc.invalidateQueries({ queryKey: ipamKeys.conflicts });
|
||||
qc.invalidateQueries({ queryKey: ipamKeys.networks });
|
||||
};
|
||||
}
|
||||
|
||||
export function useCleanupOrphans() {
|
||||
const invalidate = useInvalidateIPAM();
|
||||
return useMutation({
|
||||
mutationFn: () => apiClient.post<{ released: string[]; released_count: number; errors: unknown[]; error_count: number }>('/ipam/api/cleanup-orphans', {}),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useLinkAllocations() {
|
||||
const invalidate = useInvalidateIPAM();
|
||||
return useMutation({
|
||||
mutationFn: (body?: { server_id?: number; network_id?: number }) =>
|
||||
apiClient.post<{ linked: unknown[]; not_found: unknown[] }>('/ipam/api/link-allocations', body ?? {}),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAutoAllocate() {
|
||||
const invalidate = useInvalidateIPAM();
|
||||
return useMutation({
|
||||
mutationFn: (data: { network_id: number; pool_id?: number; resource_type?: string; resource_name?: string; hostname?: string; notes?: string }) =>
|
||||
apiClient.post<IPAMAllocation>('/ipam/api/allocations/auto', data),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useNextAvailableIP() {
|
||||
return useMutation({
|
||||
mutationFn: ({ networkId, poolId }: { networkId: number; poolId?: number }) => {
|
||||
const qs = poolId ? `?pool_id=${poolId}` : '';
|
||||
return apiClient.get<{ next_available_ip: string }>(`/ipam/api/allocations/next-available/${networkId}${qs}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useIPHistoryLookup() {
|
||||
return useMutation({
|
||||
mutationFn: (ip: string) => apiClient.get<unknown[]>(`/ipam/api/history/ip/${encodeURIComponent(ip)}`),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,3 +75,66 @@ export function useDeleteAllRead() {
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Preferences & channels ====================
|
||||
|
||||
export interface NotificationPreferences {
|
||||
id: number;
|
||||
user_id: number;
|
||||
enabled: boolean;
|
||||
email_enabled: boolean;
|
||||
email_critical_only: boolean;
|
||||
telegram_enabled: boolean;
|
||||
telegram_chat_id: string | null;
|
||||
webhook_url: string | null;
|
||||
notification_levels: string[];
|
||||
notification_types: string[];
|
||||
quiet_hours_start: string | null;
|
||||
quiet_hours_end: string | null;
|
||||
}
|
||||
|
||||
export function useNotificationPreferences() {
|
||||
return useQuery({
|
||||
queryKey: notificationKeys.preferences,
|
||||
queryFn: () => apiClient.get<NotificationPreferences>('/api/notifications/preferences'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateNotificationPreferences() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: Partial<NotificationPreferences>) =>
|
||||
apiClient.put<NotificationPreferences>('/api/notifications/preferences', data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: notificationKeys.preferences }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useChannelsStatus() {
|
||||
return useQuery({
|
||||
queryKey: ['notification-channels-status'],
|
||||
queryFn: () => apiClient.get<Record<string, unknown>>('/api/notifications/channels/status'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendTestNotification() {
|
||||
return useMutation({
|
||||
mutationFn: (channel: string) =>
|
||||
apiClient.post<Record<string, unknown>>(`/api/notifications/test?channel=${encodeURIComponent(channel)}`, {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function useVerifyTelegramChat() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (chatId: string) =>
|
||||
apiClient.post<{ success: boolean; message: string; chat_id: string }>(`/api/notifications/telegram/verify?chat_id=${encodeURIComponent(chatId)}`, {}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: notificationKeys.preferences }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTelegramBotInfo() {
|
||||
return useQuery({
|
||||
queryKey: ['telegram-bot-info'],
|
||||
queryFn: () => apiClient.get<{ configured: boolean; bot_info: Record<string, unknown> | null }>('/api/notifications/telegram/bot-info'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -131,6 +131,20 @@ export function useUpdateTelegram() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useTestSMTP() {
|
||||
return useMutation({
|
||||
mutationFn: (testEmail: string) =>
|
||||
apiClient.post<{ success: boolean; message: string }>(`/settings/api/notification-channels/smtp/test?test_email=${encodeURIComponent(testEmail)}`, {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTestTelegram() {
|
||||
return useMutation({
|
||||
mutationFn: (chatId: string) =>
|
||||
apiClient.post<{ success: boolean; message: string }>(`/settings/api/notification-channels/telegram/test?chat_id=${encodeURIComponent(chatId)}`, {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAppVersion() {
|
||||
return useQuery({
|
||||
queryKey: settingsKeys.version,
|
||||
|
||||
@@ -488,6 +488,26 @@
|
||||
}
|
||||
},
|
||||
"ipam": {
|
||||
"tools": "Tools",
|
||||
"summary": "Summary",
|
||||
"recent_activity": "Recent activity",
|
||||
"select_network": "Select network",
|
||||
"auto_allocate": "Auto-allocate IP",
|
||||
"next_available": "Next available",
|
||||
"allocate_next": "Allocate next IP",
|
||||
"conflicts": "Conflicts",
|
||||
"no_conflicts": "No conflicts detected",
|
||||
"orphans": "Orphan allocations",
|
||||
"no_orphans": "No orphan allocations",
|
||||
"cleanup_orphans": "Clean up",
|
||||
"cleanup_confirm": "Release all orphan IP allocations?",
|
||||
"released": "Released",
|
||||
"unlinked": "Unlinked VMs",
|
||||
"no_unlinked": "All VMs are linked",
|
||||
"link_allocations": "Scan & link",
|
||||
"linked": "Linked",
|
||||
"ip_history": "IP history lookup",
|
||||
"pool_required_hint": "Auto-allocation requires an auto-assign pool in the network.",
|
||||
"dashboard": "IPAM Dashboard",
|
||||
"networks": "Networks",
|
||||
"networks_count": "Networks",
|
||||
@@ -561,7 +581,22 @@
|
||||
"update_confirm": "Start the update? The panel will pull the latest version, rebuild and restart — it will be briefly unavailable.",
|
||||
"update_started": "Update started",
|
||||
"update_in_progress": "Update in progress",
|
||||
"update_reset": "Reset status"
|
||||
"update_reset": "Reset status",
|
||||
"send_test": "Send test",
|
||||
"test_email_placeholder": "test@example.com",
|
||||
"test_email_required": "Enter a test email address",
|
||||
"chat_id_required": "Enter a Telegram chat ID",
|
||||
"my_notifications": "My notifications",
|
||||
"notifications_enabled": "Enable notifications",
|
||||
"email_enabled": "Email notifications",
|
||||
"email_critical_only": "Email: critical only",
|
||||
"telegram_enabled": "Telegram notifications",
|
||||
"verify": "Verify",
|
||||
"quiet_start": "Quiet hours start",
|
||||
"quiet_end": "Quiet hours end",
|
||||
"prefs_saved": "Preferences saved",
|
||||
"test_sent": "Test notification sent",
|
||||
"send_test_notification": "Send test notification"
|
||||
},
|
||||
"ssh_keys": {
|
||||
"title": "SSH Keys",
|
||||
|
||||
@@ -488,6 +488,26 @@
|
||||
}
|
||||
},
|
||||
"ipam": {
|
||||
"tools": "Инструменты",
|
||||
"summary": "Сводка",
|
||||
"recent_activity": "Недавняя активность",
|
||||
"select_network": "Выберите сеть",
|
||||
"auto_allocate": "Авто-выделение IP",
|
||||
"next_available": "Следующий свободный",
|
||||
"allocate_next": "Выделить следующий IP",
|
||||
"conflicts": "Конфликты",
|
||||
"no_conflicts": "Конфликтов не обнаружено",
|
||||
"orphans": "Осиротевшие аллокации",
|
||||
"no_orphans": "Осиротевших аллокаций нет",
|
||||
"cleanup_orphans": "Очистить",
|
||||
"cleanup_confirm": "Освободить все осиротевшие IP-аллокации?",
|
||||
"released": "Освобождено",
|
||||
"unlinked": "Несвязанные VM",
|
||||
"no_unlinked": "Все VM связаны",
|
||||
"link_allocations": "Сканировать и связать",
|
||||
"linked": "Связано",
|
||||
"ip_history": "История по IP",
|
||||
"pool_required_hint": "Авто-выделение требует пул с auto-assign в сети.",
|
||||
"dashboard": "IPAM Дашборд",
|
||||
"networks": "Сети",
|
||||
"networks_count": "Сетей",
|
||||
@@ -561,7 +581,22 @@
|
||||
"update_confirm": "Запустить обновление? Панель скачает последнюю версию, пересоберётся и перезапустится — на короткое время будет недоступна.",
|
||||
"update_started": "Обновление запущено",
|
||||
"update_in_progress": "Идёт обновление",
|
||||
"update_reset": "Сбросить статус"
|
||||
"update_reset": "Сбросить статус",
|
||||
"send_test": "Тест",
|
||||
"test_email_placeholder": "test@example.com",
|
||||
"test_email_required": "Укажите email для теста",
|
||||
"chat_id_required": "Укажите Telegram chat ID",
|
||||
"my_notifications": "Мои уведомления",
|
||||
"notifications_enabled": "Включить уведомления",
|
||||
"email_enabled": "Уведомления на email",
|
||||
"email_critical_only": "Email: только критичные",
|
||||
"telegram_enabled": "Уведомления в Telegram",
|
||||
"verify": "Проверить",
|
||||
"quiet_start": "Тихие часы с",
|
||||
"quiet_end": "Тихие часы до",
|
||||
"prefs_saved": "Настройки сохранены",
|
||||
"test_sent": "Тестовое уведомление отправлено",
|
||||
"send_test_notification": "Отправить тест"
|
||||
},
|
||||
"ssh_keys": {
|
||||
"title": "SSH ключи",
|
||||
|
||||
@@ -25,6 +25,7 @@ const IPAMNetworksPage = lazy(() => import('./features/ipam/IPAMNetworksPage.tsx
|
||||
const IPAMNetworkDetailPage = lazy(() => import('./features/ipam/IPAMNetworkDetailPage.tsx'));
|
||||
const IPAMAllocationsPage = lazy(() => import('./features/ipam/IPAMAllocationsPage.tsx'));
|
||||
const IPAMHistoryPage = lazy(() => import('./features/ipam/IPAMHistoryPage.tsx'));
|
||||
const IPAMToolsPage = lazy(() => import('./features/ipam/IPAMToolsPage.tsx'));
|
||||
const UsersPage = lazy(() => import('./features/users/UsersPage.tsx'));
|
||||
const WorkspacesPage = lazy(() => import('./features/workspaces/WorkspacesPage.tsx'));
|
||||
const LogsPage = lazy(() => import('./features/logs/LogsPage.tsx'));
|
||||
@@ -78,6 +79,7 @@ export const router = createBrowserRouter([
|
||||
{ path: '/ipam/network/:id', element: <SuspenseWrap><IPAMNetworkDetailPage /></SuspenseWrap> },
|
||||
{ path: '/ipam/allocations', element: <SuspenseWrap><IPAMAllocationsPage /></SuspenseWrap> },
|
||||
{ path: '/ipam/history', element: <SuspenseWrap><IPAMHistoryPage /></SuspenseWrap> },
|
||||
{ path: '/ipam/tools', element: <SuspenseWrap><IPAMToolsPage /></SuspenseWrap> },
|
||||
{ path: '/networks', element: <SuspenseWrap><IPAMNetworksPage /></SuspenseWrap> },
|
||||
{ path: '/users', element: <SuspenseWrap><UsersPage /></SuspenseWrap> },
|
||||
{ path: '/workspaces', element: <SuspenseWrap><WorkspacesPage /></SuspenseWrap> },
|
||||
|
||||
Reference in New Issue
Block a user