Skip to content
Closed
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
16 changes: 16 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
<properties>
<revision>3.0.0-SNAPSHOT</revision>
<java.version>21</java.version>
<spring-framework.version>7.0.6</spring-framework.version>
<jackson.version>2.20.1</jackson.version>
<lombok.version>1.18.42</lombok.version>
<spotless.version>3.0.0</spotless.version>
Expand Down Expand Up @@ -96,6 +97,11 @@
</dependencyManagement>

<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
<version>${spring-framework.version}</version>
Comment thread
stijnpotters1 marked this conversation as resolved.
</dependency>
Comment on lines +100 to +104
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This declares an explicit spring-beans dependency with a hard-coded Spring Framework version even though the project already uses the Spring Boot parent BOM. This is easy to forget to keep in sync with the rest of Spring artifacts and can cause version skew at runtime. Prefer relying on Spring Boot’s dependency management (remove the explicit spring-beans dependency/version), or override Spring Framework via the Boot-managed property only (without adding a redundant direct dependency).

Suggested change
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
<version>${spring-framework.version}</version>
</dependency>

Copilot uses AI. Check for mistakes.
<!-- Spring Boot starters -->
<dependency>
<groupId>org.springframework.boot</groupId>
Expand Down Expand Up @@ -177,6 +183,12 @@
<artifactId>Saxon-HE</artifactId>
<version>12.9</version>
</dependency>
<dependency>
<groupId>org.frankframework</groupId>
<artifactId>frankframework-management-gateway</artifactId>
<version>10.0.0</version>
<scope>compile</scope>
</dependency>
</dependencies>

<build>
Expand Down Expand Up @@ -222,6 +234,10 @@
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
Expand Down
105 changes: 93 additions & 12 deletions src/main/frontend/app/routes/projectlanding/project-landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from '~/services/project-service'
import { useRecentProjects } from '~/hooks/use-projects'
import { showErrorToast } from '~/components/toast'
import { discoverFrankInstances, type FrankInstance } from '~/services/hazelcast-service'

export default function ProjectLanding() {
const navigate = useNavigate()
Expand All @@ -40,6 +41,8 @@ export default function ProjectLanding() {
const [isLocalEnvironment, setIsLocalEnvironment] = useState(true)
const [rootLocationName, setRootLocationName] = useState('Computer')
const [isOpeningProject, setIsOpeningProject] = useState(false)
const [frankInstances, setFrankInstances] = useState<FrankInstance[]>([])
const [isDiscovering, setIsDiscovering] = useState(false)
const importInputRef = useRef<HTMLInputElement>(null)

useEffect(() => {
Expand Down Expand Up @@ -67,6 +70,29 @@ export default function ProjectLanding() {
}
}, [apiError])

useEffect(() => {
if (!isLocalEnvironment) return
const controller = new AbortController()

const discover = () => {
discoverFrankInstances(controller.signal)
.then(setFrankInstances)
.catch(() => {
// Discovery failure is non-critical; Hazelcast may not be running
})
.finally(() => setIsDiscovering(false))
}

setIsDiscovering(true)
discover()
const interval = setInterval(discover, 3000)

Comment thread
stijnpotters1 marked this conversation as resolved.
return () => {
controller.abort()
clearInterval(interval)
}
}, [isLocalEnvironment])
Comment on lines +73 to +94
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The polling loop can start a new discoverFrankInstances request every 3s even if the previous request hasn’t completed yet, which can lead to overlapping in-flight requests and unnecessary load. Consider guarding against concurrent runs (e.g., track an in-flight flag) or aborting the previous request before starting a new one, and ensure state updates are skipped after cleanup to avoid setting state on an unmounted component.

Copilot uses AI. Check for mistakes.

const handleOpenProject = useCallback(
async (rootPath: string) => {
setIsOpeningProject(true)
Expand Down Expand Up @@ -155,6 +181,27 @@ export default function ProjectLanding() {
}
}

const handleConnectToInstance = useCallback(
async (instance: FrankInstance) => {
const path = instance.projectPath
if (!path) {
showErrorToast(`No configuration path available for "${instance.name}"`)
return
}
setIsOpeningProject(true)
try {
const project = await openProject(path)
setProject(project)
navigate(`/studio/${encodeURIComponent(project.name)}`)
} catch (error) {
showErrorToast(error instanceof Error ? error.message : `Failed to open remote instance "${instance.name}"`)
} finally {
setIsOpeningProject(false)
}
},
[navigate, setProject],
)

const projects = recentProjects ?? []
const filteredProjects = projects.filter((project) => project.name.toLowerCase().includes(searchTerm.toLowerCase()))

Expand Down Expand Up @@ -183,6 +230,9 @@ export default function ProjectLanding() {
onProjectClick={handleOpenProject}
onRemoveProject={onRemoveProject}
onExportProject={onExportProject}
frankInstances={frankInstances}
isDiscovering={isDiscovering}
onConnectToInstance={handleConnectToInstance}
/>
</div>

Expand Down Expand Up @@ -264,27 +314,58 @@ const ProjectList = ({
onProjectClick,
onRemoveProject,
onExportProject,
frankInstances,
isDiscovering,
onConnectToInstance,
}: {
projects: RecentProject[]
isLocal: boolean
onProjectClick: (rootPath: string) => void
onRemoveProject: (rootPath: string) => void
onExportProject: (projectName: string) => void
frankInstances: FrankInstance[]
isDiscovering: boolean
onConnectToInstance: (instance: FrankInstance) => void
}) => (
<section className="h-full flex-1 overflow-y-auto p-4">
{projects.length === 0 ? (
{frankInstances.length > 0 && (
<div className="mb-4">
<p className="mb-2 text-xs font-semibold tracking-wider text-slate-500 uppercase">Remote</p>
{frankInstances.map((instance) => (
<div
key={instance.id ?? instance.name}
className="hover:bg-backdrop mb-2 flex w-full cursor-pointer items-center justify-between rounded px-3 py-2"
onClick={() => onConnectToInstance(instance)}
>
<div className="flex flex-col">
<div className="font-medium">{instance.name}</div>
{instance.projectPath && <p className="text-foreground-muted text-xs">{instance.projectPath}</p>}
</div>
<span className="rounded bg-green-100 px-2 py-0.5 text-xs text-green-700">Live</span>
</div>
))}
</div>
)}
{isDiscovering && frankInstances.length === 0 && (
<p className="mb-2 text-xs text-slate-400 italic">Scanning for remote instances...</p>
)}
{projects.length === 0 && frankInstances.length === 0 && !isDiscovering && (
<p className="text-muted-foreground mt-10 text-center text-sm italic">No projects found</p>
) : (
projects.map((project) => (
<ProjectRow
key={project.rootPath}
project={project}
isLocal={isLocal}
onClick={() => onProjectClick(project.rootPath)}
onRemove={() => onRemoveProject(project.rootPath)}
onExport={() => onExportProject(project.name)}
/>
))
)}
{projects.length > 0 && (
<>
<p className="mb-2 text-xs font-semibold tracking-wider text-slate-400 uppercase">Recent</p>
{projects.map((project) => (
<ProjectRow
key={project.rootPath}
project={project}
isLocal={isLocal}
onClick={() => onProjectClick(project.rootPath)}
onRemove={() => onRemoveProject(project.rootPath)}
onExport={() => onExportProject(project.name)}
/>
))}
</>
)}
</section>
)
Expand Down
11 changes: 11 additions & 0 deletions src/main/frontend/app/services/hazelcast-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { apiFetch } from '~/utils/api'

export interface FrankInstance {
name: string
id: string
projectPath: string | null
}

export async function discoverFrankInstances(signal?: AbortSignal): Promise<FrankInstance[]> {
return apiFetch<FrankInstance[]>('/hazelcast/instances', { signal })
}
13 changes: 10 additions & 3 deletions src/main/java/org/frankframework/flow/FlowApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
import org.springframework.context.annotation.Bean;
import org.springframework.core.io.ClassPathResource;
import org.springframework.scheduling.annotation.EnableScheduling;
Expand All @@ -16,11 +18,16 @@

@SpringBootApplication
@EnableScheduling
public class FlowApplication {
@ConfigurationPropertiesScan
public class FlowApplication extends SpringBootServletInitializer {

public static void main(String[] args) {
SpringApplication app = configureApplication();
app.run(args);
SpringApplication.run(FlowApplication.class, args);
}

@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
return builder.sources(FlowApplication.class);
}
Comment thread
stijnpotters1 marked this conversation as resolved.

public static SpringApplication configureApplication() {
Expand Down
31 changes: 31 additions & 0 deletions src/main/java/org/frankframework/flow/FlowWarInitializer.java
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package org.frankframework.flow;
/*
Copyright 2023 WeAreFrank!

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;

/**
* Spring Boot entrypoint when running as a WAR application deployed to an external servlet container.
* For standalone JAR execution, see {@link FlowApplication}.
*/
public class FlowWarInitializer extends SpringBootServletInitializer {

@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
return builder.sources(FlowApplication.class);
}
}
Comment on lines +18 to +31
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FlowApplication already extends SpringBootServletInitializer, and this class adds a second WebApplicationInitializer for WAR deployments. In a servlet container, having two SpringBootServletInitializer subclasses can trigger multiple application context initializations. Keep only one initializer (either remove this class, or stop extending SpringBootServletInitializer in FlowApplication).

Suggested change
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
/**
* Spring Boot entrypoint when running as a WAR application deployed to an external servlet container.
* For standalone JAR execution, see {@link FlowApplication}.
*/
public class FlowWarInitializer extends SpringBootServletInitializer {
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
return builder.sources(FlowApplication.class);
}
}
/**
* Spring Boot entrypoint when running as a WAR application deployed to an external servlet container.
* For standalone JAR execution, see {@link FlowApplication}.
* <p>
* This class is retained only for backward compatibility. Actual initialization
* is handled by {@link FlowApplication}, which extends SpringBootServletInitializer.
*/
public class FlowWarInitializer {
// No-op: initialization is performed by FlowApplication.
}

Copilot uses AI. Check for mistakes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package org.frankframework.flow.common.config;

import org.apache.commons.lang3.StringUtils;
import org.frankframework.management.bus.LocalGateway;
import org.frankframework.management.bus.OutboundGatewayFactory;
import org.frankframework.management.gateway.HazelcastOutboundGateway;
import org.frankframework.management.security.JwtKeyGenerator;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.integration.channel.PublishSubscribeChannel;
import org.springframework.messaging.SubscribableChannel;

@Configuration
@ConditionalOnProperty(name = "hazelcast.enabled", havingValue = "true")
public class HazelcastConfig {

@Value("${configurations.directory:}")
private String configurationsDirectory;

@Bean
public JwtKeyGenerator jwtKeyGenerator() {
return new JwtKeyGenerator();
}

@Bean("frank-management-bus")
@ConditionalOnProperty(name = "configurations.directory")
public SubscribableChannel frankManagementBus() {
return new PublishSubscribeChannel();
}
Comment thread
stijnpotters1 marked this conversation as resolved.

@Bean
public OutboundGatewayFactory outboundGatewayFactory() {
OutboundGatewayFactory factory = new OutboundGatewayFactory();
String gatewayClassName = StringUtils.isNotBlank(configurationsDirectory)
? LocalGateway.class.getCanonicalName()
: HazelcastOutboundGateway.class.getCanonicalName();
factory.setGatewayClassname(gatewayClassName);
return factory;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package org.frankframework.flow.common.config;

import java.util.Arrays;
import java.util.List;
import org.frankframework.lifecycle.DynamicRegistration.Servlet;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.config.annotation.web.configurers.FormLoginConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.config.annotation.web.configurers.LogoutConfigurer;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.util.matcher.AnyRequestMatcher;

/**
* Enable security, although it's anonymous on all endpoints, but at least sets the
* SecurityContextHolder.getContext().getAuthentication() object.
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(jsr250Enabled = true, prePostEnabled = false)
@Order(Ordered.HIGHEST_PRECEDENCE)
public class SecurityChainConfigurer {

@Bean
public SecurityFilterChain configureChain(HttpSecurity http) {
http.headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin));
http.csrf(CsrfConfigurer::disable);
http.securityMatcher(AnyRequestMatcher.INSTANCE);
http.formLogin(FormLoginConfigurer::disable);
http.logout(LogoutConfigurer::disable);
http.anonymous(anonymous -> anonymous.authorities(getAuthorities()));
http.authorizeHttpRequests(requests ->
requests.requestMatchers(AnyRequestMatcher.INSTANCE).permitAll());

return http.build();
}

private List<GrantedAuthority> getAuthorities() {
return Arrays.stream(Servlet.ALL_IBIS_USER_ROLES)

Check failure on line 47 in src/main/java/org/frankframework/flow/common/config/SecurityChainConfigurer.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use static access with "org.frankframework.lifecycle.DynamicRegistration" for "ALL_IBIS_USER_ROLES".

See more on https://sonarcloud.io/project/issues?id=frankframework_flow&issues=AZ0gH2vfyvgWYOgMCE_y&open=AZ0gH2vfyvgWYOgMCE_y&pullRequest=381
.map(role -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + role))
.toList();
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package org.frankframework.flow.common.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.frankframework.management.gateway.InputStreamHttpMessageConverter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.method.HandlerTypePredicate;
Expand Down Expand Up @@ -43,4 +45,9 @@ public ObjectMapper objectMapper() {
public RestTemplate restTemplate() {
return new RestTemplate();
}

@Bean
public HttpMessageConverter<?> inputStreamHttpMessageConverter() {
return new InputStreamHttpMessageConverter();
}
}
Loading
Loading