diff --git a/app/assets/css/react-datetime-picker-override.css b/app/assets/css/react-datetime-picker-override.css index f834265ce..acd26fb58 100644 --- a/app/assets/css/react-datetime-picker-override.css +++ b/app/assets/css/react-datetime-picker-override.css @@ -5,82 +5,110 @@ library css for buttons is overriden by `.widget .widget-body button` so we have to force margin: 0 */ -.react-datetime-picker .react-calendar button { +.react-daterange-picker__calendar .react-calendar button { margin: 0 !important; } /* - Extending Calendar.css from react-datetime-picker + Extending Calendar.css from react-daterange-picker__calendar */ -.react-datetime-picker .react-calendar { +.react-daterange-picker__calendar .react-calendar { background: var(--bg-calendar-color); color: var(--text-main-color); } /* calendar nav buttons */ -.react-datetime-picker .react-calendar__navigation button:disabled { - background-color: var(--bg-calendar-color); +.react-daterange-picker__calendar .react-calendar__navigation button:disabled { + background: var(--bg-calendar-color); @apply opacity-60; @apply brightness-95 th-dark:brightness-110; } -.react-datetime-picker .react-calendar__navigation button:enabled:hover, -.react-datetime-picker .react-calendar__navigation button:enabled:focus { - background-color: var(--bg-daterangepicker-color); +.react-daterange-picker__calendar .react-calendar__navigation button:enabled:hover, +.react-daterange-picker__calendar .react-calendar__navigation button:enabled:focus { + background: var(--bg-daterangepicker-color); } /* date tile */ -.react-datetime-picker .react-calendar__tile:disabled { - background-color: var(--bg-calendar-color); +.react-daterange-picker__calendar .react-calendar__tile:disabled { + background: var(--bg-calendar-color); @apply opacity-60; @apply brightness-95 th-dark:brightness-110; } -.react-datetime-picker .react-calendar__tile:enabled:hover, -.react-datetime-picker .react-calendar__tile:enabled:focus { - background-color: var(--bg-daterangepicker-hover); +.react-daterange-picker__calendar .react-calendar__tile:enabled:hover, +.react-daterange-picker__calendar .react-calendar__tile:enabled:focus { + background: var(--bg-daterangepicker-hover); } /* today's date tile */ -.react-datetime-picker .react-calendar__tile--now { - /* use background color to avoid white on yellow in dark/high contrast modes */ +.react-daterange-picker__calendar .react-calendar__tile--now { @apply th-highcontrast:text-[color:var(--bg-calendar-color)] th-dark:text-[color:var(--bg-calendar-color)]; + border-radius: 0.25rem !important; } -.react-datetime-picker .react-calendar__tile--now:enabled:hover, -.react-datetime-picker .react-calendar__tile--now:enabled:focus { +.react-daterange-picker__calendar .react-calendar__tile--now:enabled:hover, +.react-daterange-picker__calendar .react-calendar__tile--now:enabled:focus { background: var(--bg-daterangepicker-hover); color: var(--text-daterangepicker-hover); } /* probably date tile in range */ -.react-datetime-picker .react-calendar__tile--hasActive { +.react-daterange-picker__calendar .react-calendar__tile--hasActive { background: var(--bg-daterangepicker-end-date); color: var(--text-daterangepicker-end-date); } -.react-datetime-picker .react-calendar__tile--hasActive:enabled:hover, -.react-datetime-picker .react-calendar__tile--hasActive:enabled:focus { +.react-daterange-picker__calendar .react-calendar__tile--hasActive:enabled:hover, +.react-daterange-picker__calendar .react-calendar__tile--hasActive:enabled:focus { background: var(--bg-daterangepicker-hover); color: var(--text-daterangepicker-hover); } -/* selected date tile */ -.react-datetime-picker .react-calendar__tile--active { - background: var(--bg-daterangepicker-active); - color: var(--text-daterangepicker-active); -} -.react-datetime-picker .react-calendar__tile--active:enabled:hover, -.react-datetime-picker .react-calendar__tile--active:enabled:focus { +.react-daterange-picker__calendar .react-calendar__tile--active:enabled:hover, +.react-daterange-picker__calendar .react-calendar__tile--active:enabled:focus { background: var(--bg-daterangepicker-hover); color: var(--text-daterangepicker-hover); } +.react-daterange-picker__calendar + .react-calendar__month-view__days__day:hover:not(.react-daterange-picker__calendar .react-calendar__tile--hoverEnd):not( + .react-daterange-picker__calendar .react-calendar__tile--hoverStart + ):not(.react-calendar__tile--active) { + border-radius: 0.25rem !important; +} + /* on range select hover */ -.react-datetime-picker .react-calendar--selectRange .react-calendar__tile--hover { - background-color: var(--bg-daterangepicker-in-range); +.react-daterange-picker__calendar .react-calendar--selectRange .react-calendar__tile--hover { + background: var(--bg-daterangepicker-in-range); color: var(--text-daterangepicker-in-range); } /* - Extending DateTimePicker.css from react-datetime-picker + Extending DateTimePicker.css from react-daterange-picker__calendar */ -.react-datetime-picker .react-datetime-picker--disabled { +.react-daterange-picker__calendar .react-daterange-picker__calendar--disabled { @apply opacity-40; } + +/* selected date tile */ +.react-daterange-picker__calendar .react-calendar__tile--active { + background: var(--bg-daterangepicker-active) !important; + color: var(--text-daterangepicker-active) !important; +} + +.react-daterange-picker__calendar .react-calendar__tile--rangeStart:not(.react-calendar__tile--rangeEnd), +.react-daterange-picker__calendar .react-calendar__tile--hoverStart { + border-top-left-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; +} + +.react-daterange-picker__calendar .react-calendar__tile--rangeEnd:not(.react-calendar__tile--rangeStart), +.react-daterange-picker__calendar .react-calendar__tile--hoverEnd { + border-top-right-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; +} + +.react-daterange-picker__calendar .react-calendar__month-view__days__day--weekend { + color: inherit; +} + +.react-calendar__tile--active.react-calendar__month-view__days__day--weekend { + color: var(--text-daterangepicker-active); +} diff --git a/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.viewModel.ts b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.viewModel.ts index fa1cc11c7..16f5b4363 100644 --- a/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.viewModel.ts +++ b/app/react/docker/containers/CreateView/BaseForm/PortsMappingField.viewModel.ts @@ -14,13 +14,8 @@ type StringPortBinding = { containerPort: number; }; -type NumericPortBinding = { - hostPort: number; - protocol: Protocol; - containerPort: number; -}; - type RangePortBinding = { + hostIp: string; hostPort: Range; protocol: Protocol; containerPort: Range; @@ -42,9 +37,7 @@ export function toViewModel(portBindings: PortMap): Values { return value === 'tcp' || value === 'udp'; } - function parsePorts( - portBindings: PortMap - ): Array { + function parsePorts(portBindings: PortMap): Array { return Object.entries(portBindings).flatMap(([key, bindings]) => { const [containerPort, protocol] = key.split('/'); @@ -63,15 +56,24 @@ export function toViewModel(portBindings: PortMap): Values { } return bindings.map((binding) => { + let port = ''; + if (binding.HostPort) { + port = binding.HostPort; + } + if (binding.HostIp) { + port = `${binding.HostIp}:${port}`; + } + if (binding.HostPort?.includes('-')) { + // Range port return { - hostPort: binding.HostPort, + hostPort: port, protocol, containerPort: containerPortNumber, }; } return { - hostPort: parseInt(binding.HostPort || '0', 10), + hostPort: port, protocol, containerPort: containerPortNumber, }; @@ -79,9 +81,9 @@ export function toViewModel(portBindings: PortMap): Values { }); } - function sortPorts(ports: Array) { - const rangePorts = ports.filter(isStringPortBinding); - const nonRangePorts = ports.filter(isNumericPortBinding); + function sortPorts(ports: Array) { + const rangePorts = ports.filter(isRangePortBinding); + const nonRangePorts = ports.filter((port) => !isRangePortBinding(port)); return { rangePorts, @@ -93,27 +95,40 @@ export function toViewModel(portBindings: PortMap): Values { }; } - function combinePorts(ports: Array) { + function combinePorts(ports: Array) { return ports .reduce((acc, port) => { + let hostIp = ''; + let hostPort = 0; + if (port.hostPort.includes(':')) { + const [ipStr, portStr] = port.hostPort.split(':'); + hostIp = ipStr; + hostPort = parseInt(portStr || '0', 10); + } else { + hostPort = parseInt(port.hostPort || '0', 10); + } + const lastPort = acc[acc.length - 1]; if ( lastPort && + lastPort.hostIp === hostIp && lastPort.containerPort.end === port.containerPort - 1 && - lastPort.hostPort.end === port.hostPort - 1 && + lastPort.hostPort.end === hostPort - 1 && lastPort.protocol === port.protocol ) { + lastPort.hostIp = hostIp; lastPort.containerPort.end = port.containerPort; - lastPort.hostPort.end = port.hostPort; + lastPort.hostPort.end = hostPort; return acc; } return [ ...acc, { + hostIp, hostPort: { - start: port.hostPort, - end: port.hostPort, + start: hostPort, + end: hostPort, }, containerPort: { start: port.containerPort, @@ -123,34 +138,32 @@ export function toViewModel(portBindings: PortMap): Values { }, ]; }, [] as Array) - .map(({ protocol, containerPort, hostPort }) => ({ - hostPort: getRange(hostPort.start, hostPort.end), + .map(({ protocol, containerPort, hostPort, hostIp }) => ({ + hostPort: getRange(hostPort.start, hostPort.end, hostIp), containerPort: getRange(containerPort.start, containerPort.end), protocol, })); - function getRange(start: number, end: number): string { + function getRange(start: number, end: number, hostIp?: string): string { if (start === end) { if (start === 0) { return ''; } + if (hostIp) { + return `${hostIp}:${start}`; + } return start.toString(); } + if (hostIp) { + return `${hostIp}:${start}-${end}`; + } return `${start}-${end}`; } } } -function isNumericPortBinding( - port: StringPortBinding | NumericPortBinding -): port is NumericPortBinding { - return port.hostPort !== 'string'; -} - -function isStringPortBinding( - port: StringPortBinding | NumericPortBinding -): port is StringPortBinding { - return port.hostPort === 'string'; +function isRangePortBinding(port: StringPortBinding): boolean { + return port.hostPort.includes('-'); } diff --git a/app/react/docker/images/queries/useBuildImageMutation.ts b/app/react/docker/images/queries/useBuildImageMutation.ts index 557fd6ad3..ee9eca5df 100644 --- a/app/react/docker/images/queries/useBuildImageMutation.ts +++ b/app/react/docker/images/queries/useBuildImageMutation.ts @@ -57,10 +57,15 @@ export async function buildImageFromDockerfileContentAndFiles( const dockerfile = new Blob([content], { type: 'text/plain' }); const uploadFiles = [dockerfile, ...files]; + const formData = new FormData(); + uploadFiles.forEach((file, index) => { + formData.append(`file${index}`, file); + }); + return buildImage( environmentId, { t: names }, - { file: uploadFiles }, + formData, 'multipart/form-data' ); } diff --git a/app/react/portainer/logs/ActivityLogsView/types.ts b/app/react/portainer/logs/ActivityLogsView/types.ts index 237c8a257..76176e7ad 100644 --- a/app/react/portainer/logs/ActivityLogsView/types.ts +++ b/app/react/portainer/logs/ActivityLogsView/types.ts @@ -1,8 +1,19 @@ -export interface ActivityLog { +interface BaseActivityLog { timestamp: number; action: string; context: string; id: number; - payload: object; username: string; } +export interface ActivityLogResponse extends BaseActivityLog { + payload: string; +} + +export interface ActivityLog extends BaseActivityLog { + payload: string | object; +} + +export interface ActivityLogsResponse { + logs: Array; + totalCount: number; +} diff --git a/app/react/portainer/logs/ActivityLogsView/useActivityLogs.ts b/app/react/portainer/logs/ActivityLogsView/useActivityLogs.ts index 65a246f10..f55b64416 100644 --- a/app/react/portainer/logs/ActivityLogsView/useActivityLogs.ts +++ b/app/react/portainer/logs/ActivityLogsView/useActivityLogs.ts @@ -4,7 +4,7 @@ import axios, { parseAxiosError } from '@/portainer/services/axios'; import { isBE } from '../../feature-flags/feature-flags.service'; -import { ActivityLog } from './types'; +import { ActivityLogResponse, ActivityLogsResponse } from './types'; export const sortKeys = ['Context', 'Action', 'Timestamp', 'Username'] as const; export type SortKey = (typeof sortKeys)[number]; @@ -30,19 +30,18 @@ export function useActivityLogs(query: Query) { queryKey: ['activityLogs', query] as const, queryFn: () => fetchActivityLogs(query), keepPreviousData: true, + select: (data) => ({ + ...data, + logs: decorateLogs(data.logs), + }), }); } -interface ActivityLogsResponse { - logs: Array; - totalCount: number; -} - async function fetchActivityLogs(query: Query): Promise { try { if (!isBE) { return { - logs: [{}, {}, {}, {}, {}] as Array, + logs: [{}, {}, {}, {}, {}] as Array, totalCount: 5, }; } @@ -56,3 +55,40 @@ async function fetchActivityLogs(query: Query): Promise { throw parseAxiosError(err, 'Failed loading user activity logs csv'); } } + +/** + * Decorates logs with the payload parsed from base64 + */ +function decorateLogs(logs?: ActivityLogResponse[]) { + if (!logs || logs.length === 0) { + return []; + } + + return logs.map((log) => ({ + ...log, + payload: parseBase64AsObject(log.payload), + })); +} + +function parseBase64AsObject(value: string): string | object { + if (!value) { + return value; + } + try { + return JSON.parse(safeAtob(value)); + } catch (err) { + return safeAtob(value); + } +} + +function safeAtob(value: string) { + if (!value) { + return value; + } + try { + return window.atob(value); + } catch (err) { + // If the payload is not base64 encoded, return the original value + return value; + } +} diff --git a/pkg/libstack/compose/composeplugin.go b/pkg/libstack/compose/composeplugin.go index f2d898dc2..1990a95ed 100644 --- a/pkg/libstack/compose/composeplugin.go +++ b/pkg/libstack/compose/composeplugin.go @@ -70,32 +70,24 @@ func withComposeService( return withCli(ctx, options, func(ctx context.Context, cli *command.DockerCli) error { composeService := compose.NewComposeService(cli) + if len(filePaths) == 0 { + return composeFn(composeService, nil) + } + + env, err := parseEnvironment(options) + if err != nil { + return err + } + configDetails := types.ConfigDetails{ - WorkingDir: options.WorkingDir, - Environment: make(map[string]string), + Environment: env, + WorkingDir: filepath.Dir(filePaths[0]), } for _, p := range filePaths { configDetails.ConfigFiles = append(configDetails.ConfigFiles, types.ConfigFile{Filename: p}) } - envFile := make(map[string]string) - - if options.EnvFilePath != "" { - env, err := dotenv.GetEnvFromFile(make(map[string]string), []string{options.EnvFilePath}) - if err != nil { - return fmt.Errorf("unable to get the environment from the env file: %w", err) - } - - maps.Copy(envFile, env) - - configDetails.Environment = env - } - - if len(configDetails.ConfigFiles) == 0 { - return composeFn(composeService, nil) - } - project, err := loader.LoadWithContext(ctx, configDetails, func(o *loader.Options) { o.SkipResolveEnvironment = true @@ -110,21 +102,20 @@ func withComposeService( return fmt.Errorf("failed to load the compose file: %w", err) } - if options.EnvFilePath != "" { - // Work around compose path handling - for i, service := range project.Services { - for j, envFile := range service.EnvFiles { - if !filepath.IsAbs(envFile.Path) { - project.Services[i].EnvFiles[j].Path = filepath.Join(project.WorkingDir, envFile.Path) - } + // Work around compose path handling + for i, service := range project.Services { + for j, envFile := range service.EnvFiles { + if !filepath.IsAbs(envFile.Path) { + project.Services[i].EnvFiles[j].Path = filepath.Join(configDetails.WorkingDir, envFile.Path) } } + } - if p, err := project.WithServicesEnvironmentResolved(true); err == nil { - project = p - } else { - return fmt.Errorf("failed to resolve services environment: %w", err) - } + // Set the services environment variables + if p, err := project.WithServicesEnvironmentResolved(true); err == nil { + project = p + } else { + return fmt.Errorf("failed to resolve services environment: %w", err) } return composeFn(composeService, project) @@ -136,6 +127,8 @@ func (c *ComposeDeployer) Deploy(ctx context.Context, filePaths []string, option return withComposeService(ctx, filePaths, options.Options, func(composeService api.Service, project *types.Project) error { addServiceLabels(project, false) + project = project.WithoutUnnecessaryResources() + var opts api.UpOptions if options.ForceRecreate { opts.Create.Recreate = api.RecreateForce @@ -144,6 +137,10 @@ func (c *ComposeDeployer) Deploy(ctx context.Context, filePaths []string, option opts.Create.RemoveOrphans = options.RemoveOrphans opts.Start.CascadeStop = options.AbortOnContainerExit + if err := composeService.Build(ctx, project, api.BuildOptions{}); err != nil { + return fmt.Errorf("compose build operation failed: %w", err) + } + if err := composeService.Up(ctx, project, opts); err != nil { return fmt.Errorf("compose up operation failed: %w", err) } @@ -256,10 +253,36 @@ func addServiceLabels(project *types.Project, oneOff bool) { api.ProjectLabel: project.Name, api.ServiceLabel: s.Name, api.VersionLabel: api.ComposeVersion, - api.WorkingDirLabel: "/", + api.WorkingDirLabel: project.WorkingDir, api.ConfigFilesLabel: strings.Join(project.ComposeFiles, ","), api.OneoffLabel: oneOffLabel, } project.Services[i] = s } } + +func parseEnvironment(options libstack.Options) (map[string]string, error) { + env := make(map[string]string) + + for _, envLine := range options.Env { + e, err := dotenv.UnmarshalWithLookup(envLine, nil) + if err != nil { + return nil, fmt.Errorf("unable to parse environment variables: %w", err) + } + + maps.Copy(env, e) + } + + if options.EnvFilePath == "" { + return env, nil + } + + e, err := dotenv.GetEnvFromFile(make(map[string]string), []string{options.EnvFilePath}) + if err != nil { + return nil, fmt.Errorf("unable to get the environment from the env file: %w", err) + } + + maps.Copy(env, e) + + return env, nil +} diff --git a/vitest.config.mts b/vitest.config.mts index 89caa757a..653cb488d 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -14,6 +14,9 @@ export default defineConfig({ }, bail: 2, include: ['./app/**/*.test.ts', './app/**/*.test.tsx'], + env: { + PORTAINER_EDITION: 'CE', + }, }, plugins: [svgr({ include: /\?c$/ }), tsconfigPaths()], });