Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using Sail.AuthenticationPolicy.Models;
using Sail.Extensions;

namespace Sail.AuthenticationPolicy.Http;

Expand All @@ -13,8 +14,10 @@ public static RouteGroupBuilder MapAuthenticationPolicyApi(this IEndpointRouteBu

group.MapGet("/", GetAuthenticationPolicies);
group.MapGet("/{id:guid}", GetAuthenticationPolicy);
group.MapPost("/", CreateAuthenticationPolicy);
group.MapPut("/{id:guid}", UpdateAuthenticationPolicy);
group.MapPost("/", CreateAuthenticationPolicy)
.AddRequestValidation<AuthenticationPolicyRequest>();
group.MapPut("/{id:guid}", UpdateAuthenticationPolicy)
.AddRequestValidation<AuthenticationPolicyRequest>();
group.MapDelete("/{id:guid}", DeleteAuthenticationPolicy);

return group;
Expand Down
12 changes: 8 additions & 4 deletions src/Sail/Certificate/Http/CertificateHttpEndpointsBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,17 @@ public static RouteGroupBuilder MapCertificateApiV1(this IEndpointRouteBuilder a

api.MapGet("/", GetItems);
api.MapGet("/{id:guid}", GetItem);
api.MapPost("/", Create);
api.MapPut("/{id:guid}", Update);
api.MapPost("/", Create)
.AddRequestValidation<CertificateRequest>();
api.MapPut("/{id:guid}", Update)
.AddRequestValidation<CertificateRequest>();
api.MapDelete("/{id:guid}", Delete);

api.MapGet("/{certificateId:guid}/snis", GetSNIs);
api.MapPost("/{certificateId:guid}/snis", CreateSNI);
api.MapPost("/{certificateId:guid}/snis/{id:guid}", UpdateSNI);
api.MapPost("/{certificateId:guid}/snis", CreateSNI)
.AddRequestValidation<SNIRequest>();
api.MapPost("/{certificateId:guid}/snis/{id:guid}", UpdateSNI)
.AddRequestValidation<SNIRequest>();
api.MapDelete("/{certificateId:guid}/snis/{id:guid}", DeleteSNI);
return api;
}
Expand Down
6 changes: 4 additions & 2 deletions src/Sail/Cluster/Http/ClusterHttpEndpointsBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ public static RouteGroupBuilder MapClusterApiV1(this IEndpointRouteBuilder app)

api.MapGet("/", List);
api.MapGet("/{id:guid}", Get);
api.MapPost("/", Create);
api.MapPut("/{id:guid}", Update);
api.MapPost("/", Create)
.AddRequestValidation<ClusterRequest>();
api.MapPut("/{id:guid}", Update)
.AddRequestValidation<ClusterRequest>();
api.MapDelete("/{id:guid}", Delete);
return api;
}
Expand Down
2 changes: 1 addition & 1 deletion src/Sail/Cluster/Models/ClusterRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ namespace Sail.Cluster.Models;

public record ClusterRequest(
string Name,
string ServiceName,
string? ServiceName,
ServiceDiscoveryType? ServiceDiscoveryType,
HealthCheckRequest? HealthCheck,
SessionAffinityRequest? SessionAffinity,
Expand Down
6 changes: 5 additions & 1 deletion src/Sail/Cluster/Validators/ClusterRequestValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,13 @@ public ClusterRequestValidator()
.NotEmpty()
.WithMessage(ClusterErrors.ServiceNameRequired.Description)
.WithErrorCode(ClusterErrors.ServiceNameRequired.Code)
.When(x => x.ServiceDiscoveryType.HasValue);

RuleFor(x => x.ServiceName)
.MaximumLength(200)
.WithMessage(ClusterErrors.ServiceNameTooLong.Description)
.WithErrorCode(ClusterErrors.ServiceNameTooLong.Code);
.WithErrorCode(ClusterErrors.ServiceNameTooLong.Code)
.When(x => !string.IsNullOrEmpty(x.ServiceName));

RuleFor(x => x.ServiceDiscoveryType)
.IsInEnum()
Expand Down
10 changes: 2 additions & 8 deletions src/Sail/Cluster/Validators/DestinationRequestValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,10 @@ public class DestinationRequestValidator : AbstractValidator<DestinationRequest>
public DestinationRequestValidator()
{
RuleFor(x => x.Host)
.NotEmpty()
.WithMessage(DestinationErrors.HostRequired.Description)
.WithErrorCode(DestinationErrors.HostRequired.Code)
.MaximumLength(255)
.WithMessage(DestinationErrors.HostTooLong.Description)
.WithErrorCode(DestinationErrors.HostTooLong.Code);
.WithErrorCode(DestinationErrors.HostTooLong.Code)
.When(x => !string.IsNullOrEmpty(x.Host));

RuleFor(x => x.Address)
.NotEmpty()
Expand All @@ -24,9 +22,5 @@ public DestinationRequestValidator()
.WithMessage(DestinationErrors.AddressInvalid.Description)
.WithErrorCode(DestinationErrors.AddressInvalid.Code);

RuleFor(x => x.Health)
.NotEmpty()
.WithMessage(DestinationErrors.HealthRequired.Description)
.WithErrorCode(DestinationErrors.HealthRequired.Code);
}
}
9 changes: 4 additions & 5 deletions src/Sail/Middleware/Http/MiddlewareHttpEndpointsBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ public static RouteGroupBuilder MapMiddlewareApiV1(this IEndpointRouteBuilder ap
api.MapGet("/", List);
api.MapGet("/{id:guid}", Get);
api.MapPost("/", Create)
.AddRequestValidation<MiddlewareRequest>(); ;
api.MapPut("/{id:guid}", Update);
.AddRequestValidation<MiddlewareRequest>();
api.MapPut("/{id:guid}", Update)
.AddRequestValidation<MiddlewareRequest>();
api.MapDelete("/{id:guid}", Delete);
return api;
}
Expand Down Expand Up @@ -76,6 +77,4 @@ private static async Task<Results<Ok, ProblemHttpResult>> Delete(
errors => errors.HandleErrors()
);
}
}


}
3 changes: 2 additions & 1 deletion src/Sail/Route/Http/RouteHttpEndpointsBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ public static RouteGroupBuilder MapRouteApiV1(this IEndpointRouteBuilder app)
api.MapGet("/{id:guid}", Get);
api.MapPost("/", Create)
.AddRequestValidation<RouteRequest>();
api.MapPut("/{id:guid}", Update);
api.MapPut("/{id:guid}", Update)
.AddRequestValidation<RouteRequest>();
api.MapDelete("/{id:guid}", Delete);
return api;
}
Expand Down
24 changes: 22 additions & 2 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Layout from './components/Layout/Layout';
import Dashboard from './pages/Dashboard';
Expand All @@ -15,9 +15,25 @@ import MiddlewareEdit from './pages/Middlewares/MiddlewareEdit';
import AuthenticationPoliciesPage from './pages/AuthenticationPolicies';
import AuthenticationPolicyEdit from './pages/AuthenticationPolicies/AuthenticationPolicyEdit';
import Settings from './pages/Settings';
import { NotificationProvider, useNotification } from './contexts/NotificationContext';
import { onApiError } from './services/api';
import './App.css';

const AppContent: React.FC = () => {
const { showValidationErrors, showError } = useNotification();

useEffect(() => {
const unsubscribe = onApiError(({ status, error }) => {
if (status === 422 && error.errors) {
showValidationErrors(error.errors);
} else if (status >= 400 && status < 500) {
showError(error.title || 'Request Failed', error.detail || 'An unexpected error occurred');
}
});

return unsubscribe;
}, [showValidationErrors, showError]);

return (
<Router>
<Layout>
Expand Down Expand Up @@ -48,7 +64,11 @@ const AppContent: React.FC = () => {
};

const App: React.FC = () => {
return <AppContent />;
return (
<NotificationProvider>
<AppContent />
</NotificationProvider>
);
};

export default App;
138 changes: 108 additions & 30 deletions web/src/contexts/NotificationContext.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { XMarkIcon, CheckCircleIcon, ExclamationCircleIcon, InformationCircleIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';

interface Notification {
id: string;
type: 'success' | 'error' | 'info' | 'warning';
title?: string;
message: string;
details?: string[];
duration?: number;
}

interface NotificationContextType {
notifications: Notification[];
showNotification: (type: Notification['type'], message: string, duration?: number) => void;
showValidationErrors: (errors: Record<string, string[]>) => void;
showError: (title: string, message: string, details?: string[]) => void;
removeNotification: (id: string) => void;
}

Expand All @@ -23,77 +27,151 @@ interface NotificationProviderProps {
export const NotificationProvider: React.FC<NotificationProviderProps> = ({ children }) => {
const [notifications, setNotifications] = useState<Notification[]>([]);

const removeNotification = (id: string) => {
setNotifications(prev => prev.filter(notification => notification.id !== id));
};

const showNotification = (type: Notification['type'], message: string, duration = 3000) => {
const id = Math.random().toString(36).substr(2, 9);
const notification = { id, type, message, duration };

setNotifications(prev => [...prev, notification]);

// Auto remove after duration
if (duration > 0) {
setTimeout(() => {
removeNotification(id);
}, duration);
}
};

const removeNotification = (id: string) => {
setNotifications(prev => prev.filter(notification => notification.id !== id));
const showValidationErrors = (errors: Record<string, string[]>) => {
const allErrors = Object.entries(errors).flatMap(([field, messages]) =>
messages.map(msg => `${field}: ${msg.replace(/^\[.*?\]\s*/, '')}`)
);

const id = Math.random().toString(36).substr(2, 9);
const notification = {
id,
type: 'error' as const,
title: 'Validation Error',
message: 'Please fix the following errors:',
details: allErrors,
duration: 3000
};

setNotifications(prev => [...prev, notification]);

setTimeout(() => {
removeNotification(id);
}, 3000);
};

const showError = (title: string, message: string, details?: string[]) => {
const id = Math.random().toString(36).substr(2, 9);
const notification = {
id,
type: 'error' as const,
title,
message,
details,
duration: 3000
};

setNotifications(prev => [...prev, notification]);

setTimeout(() => {
removeNotification(id);
}, 3000);
};

const getIcon = (type: Notification['type']) => {
const iconClass = "h-5 w-5";
switch (type) {
case 'success':
return <CheckCircleIcon className={`${iconClass} text-green-600`} />;
case 'error':
return <ExclamationCircleIcon className={`${iconClass} text-red-600`} />;
case 'warning':
return <ExclamationTriangleIcon className={`${iconClass} text-yellow-600`} />;
case 'info':
return <InformationCircleIcon className={`${iconClass} text-blue-600`} />;
default:
return <InformationCircleIcon className={`${iconClass} text-gray-600`} />;
}
};

const getNotificationStyles = (type: Notification['type']) => {
switch (type) {
case 'success':
return 'bg-green-50 border-green-200 text-green-800';
return 'bg-white border-l-4 border-l-green-500';
case 'error':
return 'bg-red-50 border-red-200 text-red-800';
return 'bg-white border-l-4 border-l-red-500';
case 'warning':
return 'bg-yellow-50 border-yellow-200 text-yellow-800';
return 'bg-white border-l-4 border-l-yellow-500';
case 'info':
return 'bg-blue-50 border-blue-200 text-blue-800';
return 'bg-white border-l-4 border-l-blue-500';
default:
return 'bg-gray-50 border-gray-200 text-gray-800';
return 'bg-white border-l-4 border-l-gray-500';
}
};

const getButtonStyles = (type: Notification['type']) => {
const getIconBgStyles = (type: Notification['type']) => {
switch (type) {
case 'success':
return 'text-green-500 hover:bg-green-500';
return 'bg-green-100';
case 'error':
return 'text-red-500 hover:bg-red-500';
return 'bg-red-100';
case 'warning':
return 'text-yellow-500 hover:bg-yellow-500';
return 'bg-yellow-100';
case 'info':
return 'text-blue-500 hover:bg-blue-500';
return 'bg-blue-100';
default:
return 'text-gray-500 hover:bg-gray-500';
return 'bg-gray-100';
}
};

return (
<NotificationContext.Provider value={{ notifications, showNotification, removeNotification }}>
<NotificationContext.Provider value={{ notifications, showNotification, showValidationErrors, showError, removeNotification }}>
{children}

{/* Global Notification Container */}
<div className="fixed top-20 right-4 z-40 space-y-2">
<div className="fixed top-20 right-4 z-50 space-y-3 max-w-md">
{notifications.map((notification) => (
<div
key={notification.id}
className={`rounded-lg p-4 shadow-lg max-w-sm border animate-in slide-in-from-right duration-300 ${getNotificationStyles(notification.type)}`}
className={`rounded-lg shadow-xl border border-gray-200 animate-in slide-in-from-right duration-300 ${getNotificationStyles(notification.type)}`}
>
<div className="flex items-center">
<div className="flex-1">
<p className="text-sm font-medium">
{notification.message}
</p>
<div className="p-4">
<div className="flex items-start gap-3">
<div className={`flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center ${getIconBgStyles(notification.type)}`}>
{getIcon(notification.type)}
</div>
<div className="flex-1 min-w-0 pt-0.5">
{notification.title && (
<h3 className="text-sm font-semibold text-gray-900 mb-1">
{notification.title}
</h3>
)}
<p className="text-sm text-gray-600">
{notification.message}
</p>
{notification.details && notification.details.length > 0 && (
<ul className="mt-3 text-sm text-gray-600 space-y-1.5 pl-1">
{notification.details.map((detail, index) => (
<li key={index} className="flex items-start gap-2">
<span className="text-red-500 mt-0.5">•</span>
<span className="flex-1">{detail}</span>
</li>
))}
</ul>
)}
</div>
<button
onClick={() => removeNotification(notification.id)}
className="flex-shrink-0 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-md p-1 transition-colors"
>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
<button
onClick={() => removeNotification(notification.id)}
className={`ml-3 p-1 rounded-md hover:bg-opacity-20 transition-colors ${getButtonStyles(notification.type)}`}
>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
</div>
))}
Expand Down
Loading
Loading