Skip to content
Open
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
90 changes: 87 additions & 3 deletions client/src/components/Filters/Services/Form.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';

import { Trans, useTranslation } from 'react-i18next';

import { Controller, useForm } from 'react-hook-form';

import { ServiceField } from './ServiceField';
import { ScheduleForm } from './ScheduleForm';

export type BlockedService = {
id: string;
Expand All @@ -17,6 +18,14 @@ export type ServiceGroups = {
id: string;
}

export type ServiceSchedule = {
id: string;
schedule?: {
time_zone: string;
[key: string]: any;
};
};

type FormValues = {
blocked_services: Record<string, boolean>;
};
Expand All @@ -25,7 +34,9 @@ interface FormProps {
initialValues: Record<string, boolean>;
blockedServices: BlockedService[];
serviceGroups: ServiceGroups[];
onSubmit: (values: FormValues) => void;
serviceSchedules?: ServiceSchedule[];
onSubmit: (values: FormValues & { services?: ServiceSchedule[] }) => void;
onScheduleSubmit?: (serviceId: string, schedule: any) => void;
processing: boolean;
processingSet: boolean;
}
Expand All @@ -34,11 +45,15 @@ export const Form = ({
initialValues,
blockedServices,
serviceGroups,
serviceSchedules = [],
onSubmit,
onScheduleSubmit,
processing,
processingSet,
onSubmit,
}: FormProps) => {
const { t } = useTranslation();
const [scheduleModalOpen, setScheduleModalOpen] = useState(false);
const [currentServiceId, setCurrentServiceId] = useState<string | null>(null);

const {
handleSubmit,
Expand All @@ -63,6 +78,14 @@ export const Form = ({
}, {} as Record<string, BlockedService[]>);
}, [blockedServices]);

const serviceScheduleMap = useMemo(() => {
const map: Record<string, any> = {};
serviceSchedules.forEach(svc => {
map[svc.id] = svc.schedule;
});
return map;
}, [serviceSchedules]);

const handleToggleAllServices = (isSelected: boolean) => {
blockedServices.forEach((service) => {
if (!isServicesControlsDisabled) {
Expand All @@ -80,6 +103,27 @@ export const Form = ({
});
};

const handleScheduleClick = (serviceId: string) => {
setCurrentServiceId(serviceId);
setScheduleModalOpen(true);
};

const handleScheduleSubmit = (schedule: any) => {
if (currentServiceId && onScheduleSubmit) {
onScheduleSubmit(currentServiceId, schedule);
}
setScheduleModalOpen(false);
setCurrentServiceId(null);
};

const handleDeleteSchedule = (serviceId: string) => {
if (onScheduleSubmit) {
onScheduleSubmit(serviceId, null);
}
setScheduleModalOpen(false);
setCurrentServiceId(null);
};

const handleSubmitWithGroups = (values: FormValues) => {
if (!values || !values.blocked_services) {
return onSubmit(values);
Expand All @@ -94,6 +138,8 @@ export const Form = ({
return onSubmit({ blocked_services: enabledIdsMap });
};

const currentServiceSchedule = currentServiceId ? serviceScheduleMap[currentServiceId] : null;

return (
<form onSubmit={handleSubmit(handleSubmitWithGroups)}>
<div className="form__group">
Expand Down Expand Up @@ -166,6 +212,8 @@ export const Form = ({
placeholder={service.name}
disabled={isServicesControlsDisabled}
icon={service.icon_svg}
hasSchedule={!!serviceScheduleMap[service.id]}
onScheduleClick={() => handleScheduleClick(service.id)}
/>
)}
/>
Expand All @@ -186,6 +234,42 @@ export const Form = ({
<Trans>save_btn</Trans>
</button>
</div>

{scheduleModalOpen && (
<div className="modal d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="modal-dialog modal-lg">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">
{t('schedule_services')} - {currentServiceId}
</h5>
<button
type="button"
className="close"
onClick={() => setScheduleModalOpen(false)}
>
<span>&times;</span>
</button>
</div>
<div className="modal-body">
<ScheduleForm
schedule={currentServiceSchedule || { time_zone: 'Local' }}
onScheduleSubmit={handleScheduleSubmit}
/>
{currentServiceSchedule && (
<button
type="button"
className="btn btn-danger mt-3"
onClick={() => handleDeleteSchedule(currentServiceId!)}
>
<Trans>schedule_remove</Trans>
</button>
)}
</div>
</div>
</div>
</div>
)}
</form>
);
};
21 changes: 20 additions & 1 deletion client/src/components/Filters/Services/ServiceField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ type Props = ControllerRenderProps<FieldValues> & {
className?: string;
icon?: string;
error?: string;
hasSchedule?: boolean;
onScheduleClick?: () => void;
};

export const ServiceField = React.forwardRef<HTMLInputElement, Props>(
({ name, value, onChange, onBlur, placeholder, disabled, className, icon, error, ...rest }, ref) => (
({ name, value, onChange, onBlur, placeholder, disabled, className, icon, error, hasSchedule, onScheduleClick, ...rest }, ref) => (
<>
<label className={cn('service custom-switch', className)}>
<input
Expand All @@ -32,6 +34,23 @@ export const ServiceField = React.forwardRef<HTMLInputElement, Props>(
{placeholder}
</span>
{icon && <div dangerouslySetInnerHTML={{ __html: window.atob(icon) }} className="service__icon" />}

{!disabled && (
<button
type="button"
className={cn('btn btn-icon btn-sm service__schedule-btn', { 'service__schedule-btn--active': hasSchedule })}
title="Schedule"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onScheduleClick?.();
}}
>
<svg className="icons icon12">
<use xlinkHref="#watch" />
</svg>
</button>
)}
</label>

{!disabled && error && <span className="form__message form__message--error">{error}</span>}
Expand Down
29 changes: 28 additions & 1 deletion client/src/components/Filters/Services/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';

import { useDispatch, useSelector } from 'react-redux';

import { Form } from './Form';
import { Form, ServiceSchedule } from './Form';

import Card from '../../ui/Card';
import { getBlockedServices, getAllBlockedServices, updateBlockedServices } from '../../../actions/services';
Expand Down Expand Up @@ -48,6 +48,7 @@ const Services = () => {
updateBlockedServices({
ids: blocked_services,
schedule: services.list.schedule,
services: services.list.services,
}),
);
};
Expand All @@ -57,6 +58,30 @@ const Services = () => {
updateBlockedServices({
ids: services.list.ids,
schedule: values,
services: services.list.services,
}),
);
};

const handleServiceScheduleSubmit = (serviceId: string, schedule: any) => {
const currentServices = services.list.services || [];
const existingIndex = currentServices.findIndex((s: ServiceSchedule) => s.id === serviceId);

let newServices: ServiceSchedule[];
if (schedule === null) {
newServices = currentServices.filter((s: ServiceSchedule) => s.id !== serviceId);
} else if (existingIndex >= 0) {
newServices = [...currentServices];
newServices[existingIndex] = { id: serviceId, schedule };
} else {
newServices = [...currentServices, { id: serviceId, schedule }];
}

dispatch(
updateBlockedServices({
ids: services.list.ids,
schedule: services.list.schedule,
services: newServices,
}),
);
};
Expand All @@ -77,9 +102,11 @@ const Services = () => {
initialValues={initialValues}
blockedServices={services.allServices}
serviceGroups={services.allGroups}
serviceSchedules={services.list.services}
processing={services.processing}
processingSet={services.processingSet}
onSubmit={handleSubmit}
onScheduleSubmit={handleServiceScheduleSubmit}
/>
</div>
</Card>
Expand Down
26 changes: 26 additions & 0 deletions client/src/components/Settings/Clients/Service.css
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,29 @@
opacity: 0.5;
cursor: pointer;
}

.service__schedule-btn {
margin-left: 8px;
padding: 4px;
border: none;
background: transparent;
color: #6c757d;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}

.service__schedule-btn:hover {
background-color: #e9ecef;
color: #495057;
}

.service__schedule-btn--active {
color: #cd201f;
}

.service__schedule-btn--active:hover {
background-color: rgba(205, 32, 31, 0.1);
}
61 changes: 58 additions & 3 deletions internal/filtering/blocked.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,28 @@ func initBlockedServices(ctx context.Context, l *slog.Logger) {
l.DebugContext(ctx, "initialized services", "svc_len", svcLen)
}

// ServiceSchedule represents a schedule for a single blocked service.
type ServiceSchedule struct {
ID string `json:"id" yaml:"id"`
Schedule *schedule.Weekly `json:"schedule" yaml:"schedule"`
}

// BlockedServices is the configuration of blocked services.
//
// TODO(s.chzhen): Move to a higher-level package to allow importing the client
// package into the filtering package.
type BlockedServices struct {
// Schedule is blocked services schedule for every day of the week.
// Deprecated: Use Services for per-service scheduling.
Schedule *schedule.Weekly `json:"schedule" yaml:"schedule"`

// IDs is the names of blocked services.
IDs []string `json:"ids" yaml:"ids"`

// Services is the list of services with their individual schedules.
// When a service has a schedule, it is blocked during the scheduled times
// and not blocked outside those times (regardless of whether it's in IDs).
Services []ServiceSchedule `json:"services" yaml:"services"`
}

// Clone returns a deep copy of blocked services.
Expand All @@ -77,9 +89,18 @@ func (s *BlockedServices) Clone() (c *BlockedServices) {
return nil
}

services := make([]ServiceSchedule, len(s.Services))
for i, svc := range s.Services {
services[i] = ServiceSchedule{
ID: svc.ID,
Schedule: svc.Schedule.Clone(),
}
}

return &BlockedServices{
Schedule: s.Schedule.Clone(),
IDs: slices.Clone(s.IDs),
Services: services,
}
}

Expand Down Expand Up @@ -118,6 +139,13 @@ func (s *BlockedServices) Validate() (err error) {
}
}

for _, svc := range s.Services {
_, ok := serviceRules[svc.ID]
if !ok {
errs = append(errs, fmt.Errorf("unknown blocked-service %q", svc.ID))
}
}

return errors.Join(errs...)
}

Expand All @@ -129,11 +157,38 @@ func (d *DNSFilter) ApplyBlockedServices(setts *Settings) {
setts.ServicesRules = []ServiceEntry{}

bsvc := d.conf.BlockedServices
now := time.Now()

// Start with default blocked services
blockedIDs := slices.Clone(bsvc.IDs)

// Apply per-service schedules
for _, svc := range bsvc.Services {
isScheduled := svc.Schedule != nil && svc.Schedule.Contains(now)
isInDefaultList := slices.Contains(blockedIDs, svc.ID)

if isScheduled && !isInDefaultList {
// Service has active schedule but not in default list - add it
blockedIDs = append(blockedIDs, svc.ID)
} else if !isScheduled && isInDefaultList {
// Service has no active schedule but is in default list - remove it
blockedIDs = slices.DeleteFunc(blockedIDs, func(id string) bool {
return id == svc.ID
})
}
}

// TODO(s.chzhen): Use startTime from [dnsforward.dnsContext].
if !bsvc.Schedule.Contains(time.Now()) {
d.ApplyBlockedServicesList(setts, bsvc.IDs)
// Apply legacy single schedule if no per-service schedules exist
if len(bsvc.Services) == 0 && bsvc.Schedule != nil {
if bsvc.Schedule.Contains(now) {
d.ApplyBlockedServicesList(setts, blockedIDs)
}

return
}

// Apply the computed blocked services list
d.ApplyBlockedServicesList(setts, blockedIDs)
}

// ApplyBlockedServicesList appends filtering rules to the settings.
Expand Down