feat: enhance NotificationCenter with system update notifications and improve ProxyHostForm connection source handling
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Bell, X, Info, AlertTriangle, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import { getNotifications, markNotificationRead, markAllNotificationsRead } from '../api/system';
|
||||
import { Bell, X, Info, AlertTriangle, AlertCircle, CheckCircle, ExternalLink } from 'lucide-react';
|
||||
import { getNotifications, markNotificationRead, markAllNotificationsRead, checkUpdates } from '../api/system';
|
||||
|
||||
const NotificationCenter: React.FC = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -13,6 +13,12 @@ const NotificationCenter: React.FC = () => {
|
||||
refetchInterval: 30000, // Poll every 30s
|
||||
});
|
||||
|
||||
const { data: updateInfo } = useQuery({
|
||||
queryKey: ['system-updates'],
|
||||
queryFn: checkUpdates,
|
||||
staleTime: 1000 * 60 * 60, // 1 hour
|
||||
});
|
||||
|
||||
const markReadMutation = useMutation({
|
||||
mutationFn: markNotificationRead,
|
||||
onSuccess: () => {
|
||||
@@ -27,7 +33,15 @@ const NotificationCenter: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const unreadCount = notifications.length;
|
||||
const unreadCount = notifications.length + (updateInfo?.available ? 1 : 0);
|
||||
const hasCritical = notifications.some(n => n.type === 'error');
|
||||
const hasWarning = notifications.some(n => n.type === 'warning') || updateInfo?.available;
|
||||
|
||||
const getBellColor = () => {
|
||||
if (hasCritical) return 'text-red-500 hover:text-red-600';
|
||||
if (hasWarning) return 'text-yellow-500 hover:text-yellow-600';
|
||||
return 'text-green-500 hover:text-green-600';
|
||||
};
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
@@ -42,12 +56,12 @@ const NotificationCenter: React.FC = () => {
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="relative p-2 text-gray-400 hover:text-white focus:outline-none"
|
||||
className={`relative p-2 focus:outline-none transition-colors ${getBellColor()}`}
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Bell className="w-6 h-6" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-0 right-0 inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-red-100 transform translate-x-1/4 -translate-y-1/4 bg-red-600 rounded-full">
|
||||
<span className="absolute top-0 right-0 inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-white transform translate-x-1/4 -translate-y-1/4 bg-red-600 rounded-full">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -63,7 +77,7 @@ const NotificationCenter: React.FC = () => {
|
||||
<div className="absolute right-0 z-20 w-80 mt-2 overflow-hidden bg-white rounded-md shadow-lg dark:bg-gray-800 ring-1 ring-black ring-opacity-5">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">Notifications</h3>
|
||||
{unreadCount > 0 && (
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
onClick={() => markAllReadMutation.mutate()}
|
||||
className="text-xs text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||
@@ -73,7 +87,29 @@ const NotificationCenter: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{notifications.length === 0 ? (
|
||||
{/* Update Notification */}
|
||||
{updateInfo?.available && (
|
||||
<div className="flex items-start px-4 py-3 border-b dark:border-gray-700 bg-yellow-50 dark:bg-yellow-900/10 hover:bg-yellow-100 dark:hover:bg-yellow-900/20">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<AlertCircle className="w-5 h-5 text-yellow-500" />
|
||||
</div>
|
||||
<div className="ml-3 w-0 flex-1">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Update Available: {updateInfo.latest_version}
|
||||
</p>
|
||||
<a
|
||||
href={updateInfo.changelog_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 text-sm text-blue-600 hover:text-blue-500 dark:text-blue-400 flex items-center"
|
||||
>
|
||||
View Changelog <ExternalLink className="w-3 h-3 ml-1" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notifications.length === 0 && !updateInfo?.available ? (
|
||||
<div className="px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
No new notifications
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { CircleHelp } from 'lucide-react'
|
||||
import type { ProxyHost } from '../api/proxyHosts'
|
||||
import { useRemoteServers } from '../hooks/useRemoteServers'
|
||||
import { useDocker } from '../hooks/useDocker'
|
||||
@@ -20,15 +21,31 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
hsts_enabled: host?.hsts_enabled ?? false,
|
||||
hsts_subdomains: host?.hsts_subdomains ?? false,
|
||||
block_exploits: host?.block_exploits ?? true,
|
||||
websocket_support: host?.websocket_support ?? false,
|
||||
websocket_support: host?.websocket_support ?? true,
|
||||
advanced_config: host?.advanced_config || '',
|
||||
enabled: host?.enabled ?? true,
|
||||
})
|
||||
|
||||
const { servers: remoteServers } = useRemoteServers()
|
||||
const [dockerHost, setDockerHost] = useState('')
|
||||
const [showDockerHost, setShowDockerHost] = useState(false)
|
||||
const { containers: dockerContainers, isLoading: dockerLoading, error: dockerError } = useDocker(dockerHost)
|
||||
const [connectionSource, setConnectionSource] = useState<'local' | string>('local')
|
||||
|
||||
// Fetch containers based on selected source
|
||||
// If 'local', host is undefined (which defaults to local socket in backend)
|
||||
// If remote UUID, we need to find the server and get its host address?
|
||||
// Actually, the backend ListContainers takes a 'host' query param.
|
||||
// If it's a remote server, we should probably pass the UUID or the host address.
|
||||
// Looking at backend/internal/services/docker_service.go, it takes a 'host' string.
|
||||
// If it's a remote server, we need to pass the TCP address (e.g. tcp://1.2.3.4:2375).
|
||||
|
||||
const getDockerHostString = () => {
|
||||
if (connectionSource === 'local') return undefined;
|
||||
const server = remoteServers.find(s => s.uuid === connectionSource);
|
||||
if (!server) return undefined;
|
||||
// Construct the Docker host string
|
||||
return `tcp://${server.host}:${server.port}`;
|
||||
}
|
||||
|
||||
const { containers: dockerContainers, isLoading: dockerLoading, error: dockerError } = useDocker(getDockerHostString())
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
@@ -130,28 +147,31 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
|
||||
{/* Docker Container Quick Select */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label htmlFor="quick-select-docker" className="block text-sm font-medium text-gray-300">
|
||||
Quick Select: Container
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDockerHost(!showDockerHost)}
|
||||
className="text-xs text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
{showDockerHost ? 'Hide Remote' : 'Remote Docker?'}
|
||||
</button>
|
||||
</div>
|
||||
<label htmlFor="connection-source" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Source
|
||||
</label>
|
||||
<select
|
||||
id="connection-source"
|
||||
value={connectionSource}
|
||||
onChange={e => setConnectionSource(e.target.value)}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="local">Local (Docker Socket)</option>
|
||||
{remoteServers
|
||||
.filter(s => s.provider === 'docker' && s.enabled)
|
||||
.map(server => (
|
||||
<option key={server.uuid} value={server.uuid}>
|
||||
{server.name} ({server.host})
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{showDockerHost && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="tcp://100.x.y.z:2375"
|
||||
value={dockerHost}
|
||||
onChange={(e) => setDockerHost(e.target.value)}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white text-sm mb-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="quick-select-docker" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Quick Select: Container
|
||||
</label>
|
||||
|
||||
<select
|
||||
id="quick-select-docker"
|
||||
@@ -227,6 +247,9 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">Force SSL</span>
|
||||
<div title="Redirects all HTTP traffic to HTTPS" className="text-gray-500 hover:text-gray-300 cursor-help">
|
||||
<CircleHelp size={14} />
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
@@ -236,6 +259,9 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">HTTP/2 Support</span>
|
||||
<div title="Enables HTTP/2 support for better performance" className="text-gray-500 hover:text-gray-300 cursor-help">
|
||||
<CircleHelp size={14} />
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
@@ -245,6 +271,9 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">HSTS Enabled</span>
|
||||
<div title="Enables HTTP Strict Transport Security (HSTS)" className="text-gray-500 hover:text-gray-300 cursor-help">
|
||||
<CircleHelp size={14} />
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
@@ -254,6 +283,9 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">HSTS Subdomains</span>
|
||||
<div title="Applies HSTS to all subdomains" className="text-gray-500 hover:text-gray-300 cursor-help">
|
||||
<CircleHelp size={14} />
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
@@ -262,7 +294,10 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
onChange={e => setFormData({ ...formData, block_exploits: e.target.checked })}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">Block Common Exploits</span>
|
||||
<span className="text-sm text-gray-300">Block Exploits</span>
|
||||
<div title="Blocks common exploit attempts (XSS, SQLi, etc.)" className="text-gray-500 hover:text-gray-300 cursor-help">
|
||||
<CircleHelp size={14} />
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
@@ -271,7 +306,10 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
onChange={e => setFormData({ ...formData, websocket_support: e.target.checked })}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">WebSocket Support</span>
|
||||
<span className="text-sm text-gray-300">Websockets Support</span>
|
||||
<div title="Enables WebSocket support for real-time applications" className="text-gray-500 hover:text-gray-300 cursor-help">
|
||||
<CircleHelp size={14} />
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
|
||||
@@ -91,7 +91,15 @@ export default function RemoteServerForm({ server, onSubmit, onCancel }: Props)
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">Provider</label>
|
||||
<select
|
||||
value={formData.provider}
|
||||
onChange={e => setFormData({ ...formData, provider: e.target.value })}
|
||||
onChange={e => {
|
||||
const newProvider = e.target.value;
|
||||
setFormData({
|
||||
...formData,
|
||||
provider: newProvider,
|
||||
// Set default port for Docker
|
||||
port: newProvider === 'docker' ? 2375 : (newProvider === 'generic' ? 22 : formData.port)
|
||||
})
|
||||
}}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="generic">Generic</option>
|
||||
@@ -124,15 +132,17 @@ export default function RemoteServerForm({ server, onSubmit, onCancel }: Props)
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={e => setFormData({ ...formData, username: e.target.value })}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
{formData.provider !== 'docker' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={e => setFormData({ ...formData, username: e.target.value })}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3">
|
||||
|
||||
@@ -1,53 +1,17 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { checkUpdates } from '../api/system';
|
||||
import { ExternalLink, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
|
||||
const SystemStatus: React.FC = () => {
|
||||
const [showUpToDate, setShowUpToDate] = useState(true);
|
||||
const { data: updateInfo, isLoading } = useQuery({
|
||||
// We still query for updates here to keep the cache fresh,
|
||||
// but the UI is now handled by NotificationCenter
|
||||
useQuery({
|
||||
queryKey: ['system-updates'],
|
||||
queryFn: checkUpdates,
|
||||
staleTime: 1000 * 60 * 60, // 1 hour
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (updateInfo && !updateInfo.available) {
|
||||
const timer = setTimeout(() => {
|
||||
setShowUpToDate(false);
|
||||
}, 5000); // Hide after 5 seconds
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setShowUpToDate(true);
|
||||
}
|
||||
}, [updateInfo]);
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
if (!updateInfo?.available) {
|
||||
if (!showUpToDate) return null;
|
||||
return (
|
||||
<div className="flex items-center text-sm text-green-500 transition-opacity duration-500">
|
||||
<CheckCircle className="w-4 h-4 mr-1" />
|
||||
<span className="hidden sm:inline">Up to date</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center text-sm text-yellow-500 bg-yellow-50 dark:bg-yellow-900/20 px-3 py-1 rounded-full">
|
||||
<AlertCircle className="w-4 h-4 mr-2" />
|
||||
<span className="mr-2 hidden sm:inline">Update available: {updateInfo.latest_version}</span>
|
||||
<a
|
||||
href={updateInfo.changelog_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center underline hover:text-yellow-600"
|
||||
>
|
||||
<span className="hidden sm:inline">Changelog</span> <ExternalLink className="w-3 h-3 ml-1" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
export default SystemStatus;
|
||||
|
||||
Reference in New Issue
Block a user