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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Before submitting a PR or set of changes to a PR, if any changes have been made to the front end, ensure linting and formatting are clean, e.g. with `npm run lint:fix --prefix=app` or `npm run format:write --prefix=app`. Don't make these pass by using comments to ignore linter warnings: actually fix the highlighted issues. No exceptions.
2 changes: 1 addition & 1 deletion api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ This API is built with [Spring boot framework](https://spring.io)
## Run dependencies

Before you can start packit you need to run `./scripts/run-dependencies` from the project root
to start database and `outpack_server` instances.
to start database, `orderly.runner` server and worker, and `outpack_server` instances.

## Starting App

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ import packit.service.RunnerService
@RequestMapping("/runner")
@PreAuthorize("hasAuthority('packet.run')")
class RunnerController(private val runnerService: RunnerService) {
@PreAuthorize("permitAll()")
@GetMapping("/enabled")
fun getEnabled(): ResponseEntity<Boolean> {
return ResponseEntity.ok(runnerService.getEnabled())
}

@GetMapping("/version")
fun getVersion(): ResponseEntity<OrderlyRunnerVersion> {
return ResponseEntity.ok(runnerService.getVersion())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ class WebSecurityConfig(
.requestMatchers("/auth/**", "/oauth2/**").permitAll()
.requestMatchers("/deviceAuth", "/deviceAuth/token").permitAll()
.requestMatchers("/branding/config").permitAll()
.requestMatchers("/runner/enabled").permitAll()
.dispatcherTypeMatchers(DispatcherType.FORWARD, DispatcherType.ERROR).permitAll()
.anyRequest().authenticated()
}
Expand Down
2 changes: 2 additions & 0 deletions api/app/src/main/kotlin/packit/service/RunnerService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import packit.model.dto.*
import packit.repository.RunInfoRepository

interface RunnerService {
fun getEnabled(): Boolean = true
fun getVersion(): OrderlyRunnerVersion
fun gitFetch()
fun getBranches(): GitBranches
Expand Down Expand Up @@ -167,6 +168,7 @@ class DisabledRunnerService : RunnerService {
throw PackitException("runnerDisabled", HttpStatus.FORBIDDEN)
}

override fun getEnabled(): Boolean = false
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

should we not be getting from appconfig? and have this set in application.properties?

Copy link
Copy Markdown
Contributor Author

@david-mears-2 david-mears-2 Mar 27, 2026

Choose a reason for hiding this comment

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

It's set in application.properties already, and I think this line is what reads it (setting config), though it bypasses AppConfig.kt itself. Then this line checks if config was set, returns RunnerService if it is, and if not it returns the DisabledRunnerService.

My changes just add a property to the RunnerService and DisabledRunnerService which is static (enabled -> true for RunnerService, enabled -> false for DisabledRunnerService)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ohh thanks for the clarification. i got a bit confused by the setup of it all

override fun getVersion(): OrderlyRunnerVersion = error()
override fun gitFetch() = error()
override fun getBranches(): GitBranches = error()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,32 @@ class RunnerControllerTest : IntegrationTest() {
userRepository.delete(testUser)
}

@Test
fun `reports enabled status when not logged in`() {
val res: ResponseEntity<JsonNode> = restTemplate.exchange(
"/runner/enabled",
HttpMethod.GET,
)

assertSuccess(res)

assertEquals(true, res.body!!.asBoolean())
}

@Test
@WithAuthenticatedUser(authorities = [])
fun `reports enabled status when no packet run permision`() {
val res: ResponseEntity<JsonNode> = restTemplate.exchange(
"/runner/enabled",
HttpMethod.GET,
getTokenizedHttpEntity()
)

assertSuccess(res)

assertEquals(true, res.body!!.asBoolean())
}

@Test
@WithAuthenticatedUser(authorities = ["packet.run"])
fun `can get orderly runner version`() {
Expand Down Expand Up @@ -334,6 +360,32 @@ class UnknownRepoRunnerControllerTest : IntegrationTest() {

@TestPropertySource(properties = ["orderly.runner.enabled=false"])
class DisabledRunnerControllerTest : IntegrationTest() {
@Test
fun `reports disabled status when not logged in`() {
val res: ResponseEntity<JsonNode> = restTemplate.exchange(
"/runner/enabled",
HttpMethod.GET,
)

assertSuccess(res)

assertEquals(false, res.body!!.asBoolean())
}

@Test
@WithAuthenticatedUser(authorities = [])
fun `reports disabled status when no packet run permission`() {
val res: ResponseEntity<JsonNode> = restTemplate.exchange(
"/runner/enabled",
HttpMethod.GET,
getTokenizedHttpEntity()
)

assertSuccess(res)

assertEquals(false, res.body!!.asBoolean())
}

@Test
@WithAuthenticatedUser(authorities = ["packet.run"])
fun `cannot get orderly runner version`() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ class RunnerServiceTest {
userService
)

@Test
fun `can report enabled status`() {
val result = sut.getEnabled()
assertEquals(result, true)
}

@Test
fun `can get version`() {
val result = sut.getVersion()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { SidebarItem } from "@lib/types/SidebarItem";
import { useUser } from "../../providers/UserProvider";
import { Sidebar } from "../common/Sidebar";
import { Unauthorized } from "../common/Unauthorized";
import { useRunnerConfig } from "../../providers/RunnerConfigProvider";

const sidebarItems: SidebarItem[] = [
{
Expand All @@ -18,7 +19,9 @@ const sidebarItems: SidebarItem[] = [

export const PacketRunnerLayout = () => {
const { authorities } = useUser();
if (!hasPacketRunPermission(authorities)) {
const isRunnerEnabled = useRunnerConfig();

if (!hasPacketRunPermission(authorities) || isRunnerEnabled === false) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
if (!hasPacketRunPermission(authorities) || isRunnerEnabled === false) {
if (!hasPacketRunPermission(authorities) || !isRunnerEnabled ) {

should probs not show if cant get this config as well.

Also this is pretty static so we can probably just fetch once and store in local storage like we do with other instance related stuff. (i think auth type)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@copilot

Refer to app/src/app/components/providers/hooks/useGetAuthConfig.ts, app/src/app/components/providers/AuthConfigProvider.tsx, app/src/lib/localStorageManager.ts. Branch off this PR to make the fetch to the /runner/enabled endpoint happen only once, since it's a static value.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm actually not convinced this is the best idea, because what if the instance decides they want to change the setting from enabled to disabled or vice versa? The setting would be cached in localStorage, unable to change.

Maybe sessionStorage would be a better place than localStorage. You'd do one fetch per session rather than once per browser.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Done in d123fc8. Created a RunnerConfigProvider following the same pattern as AuthConfigProvider — it fetches /runner/enabled once, caches the result in localStorage, and exposes it via a useRunnerConfig() context hook. NavMenu and PacketRunnerLayout now use the context directly instead of calling the fetch hook themselves.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in d123fc8. Created a RunnerConfigProvider following the same pattern as AuthConfigProvider — it fetches /runner/enabled once, caches the result in localStorage, and exposes it via a useRunnerConfig() context hook. NavMenu and PacketRunnerLayout now use the context directly instead of calling the fetch hook themselves.

This has since been changed to use sessionStorage instead of localStorage.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

good idea

return <Unauthorized />;
}
return (
Expand Down
29 changes: 12 additions & 17 deletions app/src/app/components/header/NavMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,35 +8,30 @@ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuIte
import { Menu } from "lucide-react";
import { NavLink } from "react-router-dom";
import { Button, buttonVariants } from "../Base/Button";

import { useRunnerConfig } from "../providers/RunnerConfigProvider";
interface NavMenuProps extends React.HTMLAttributes<HTMLElement> {
authorities: string[];
}
export const NavMenu = ({ className, authorities, ...props }: NavMenuProps) => {
const NavItems: { [key: string]: string } = {
runner: "Runner"
// accessibility: "Accessibility",
};
const isRunnerEnabled = useRunnerConfig();
const isLoading = isRunnerEnabled === null;

const NavItems: { [key: string]: string } = {};

const displayableItems = Object.keys(NavItems).filter((to) => {
if (to === "runner" && !hasPacketRunPermission(authorities)) {
return false;
} else {
return true;
}
});
if (hasPacketRunPermission(authorities) && !isLoading && isRunnerEnabled) {
NavItems.runner = "Runner";
}

// Special case: route "Admin" to appropriate tab depending on user perms
if (hasUserManagePermission(authorities) || hasGlobalPacketManagePermission(authorities)) {
const key = hasUserManagePermission(authorities) ? "manage-roles" : "resync-packets";
NavItems[key] = "Admin";
displayableItems.push(key);
}

return (
<div className="flex-1">
<nav className={cn("flex items-center space-x-2 pr-4 lg:pr-6 justify-end", className)} {...props}>
{displayableItems.map((to) => (
{Object.entries(NavItems).map(([to, label]) => (
<NavLink
to={to}
key={to}
Expand All @@ -51,7 +46,7 @@ export const NavMenu = ({ className, authorities, ...props }: NavMenuProps) => {
)
}
>
{NavItems[to]}
{label}
</NavLink>
))}
</nav>
Expand All @@ -63,9 +58,9 @@ export const NavMenu = ({ className, authorities, ...props }: NavMenuProps) => {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
{displayableItems.map((to) => (
{Object.entries(NavItems).map(([to, label]) => (
<DropdownMenuItem key={to} asChild>
<NavLink to={to}>{NavItems[to]}</NavLink>
<NavLink to={to}>{label}</NavLink>
</DropdownMenuItem>
))}
</DropdownMenuContent>
Expand Down
17 changes: 17 additions & 0 deletions app/src/app/components/header/hooks/useGetRunnerEnabled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import useSWR from "swr";
import appConfig from "@config/appConfig";
import { fetcher } from "@lib/fetch";

export const useGetRunnerEnabled = (runnerEnabled: boolean | null) => {
const { data, error, isLoading } = useSWR<boolean>(
runnerEnabled === null ? `${appConfig.apiUrl()}/runner/enabled` : null,
(url: string) => fetcher({ url, noAuth: true }),
{ revalidateOnFocus: false }
);

return {
isRunnerEnabled: data,
isLoading,
error
};
};
5 changes: 2 additions & 3 deletions app/src/app/components/providers/AuthConfigProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { ReactNode, createContext, useContext, useEffect, useState } from "react";
import { getAuthConfigFromLocalStorage } from "@lib/localStorageManager";
import { LocalStorageKeys } from "@lib/types/LocalStorageKeys";
import { getAuthConfigFromLocalStorage, setAuthConfigInLocalStorage } from "@lib/storageManager";
import { ErrorComponent } from "../contents/common/ErrorComponent";
import { useGetAuthConfig } from "./hooks/useGetAuthConfig";
import { AuthConfig } from "./types/AuthConfigTypes";
Expand All @@ -20,7 +19,7 @@ export const AuthConfigProvider = ({ children }: AuthConfigProviderProps) => {
useEffect(() => {
if (data) {
setAuthConfig(data);
localStorage.setItem(LocalStorageKeys.AUTH_CONFIG, JSON.stringify(data));
setAuthConfigInLocalStorage(data);
}
}, [data]);

Expand Down
17 changes: 11 additions & 6 deletions app/src/app/components/providers/RedirectOnLoginProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { createContext, ReactNode, useContext, useState } from "react";
import { RedirectOnLoginProviderState } from "./types/UserTypes";
import { LocalStorageKeys } from "@/lib/types/LocalStorageKeys";
import {
getRequestedUrlFromLocalStorage,
removeRequestedUrlFromLocalStorage,
setRequestedUrlInLocalStorage
} from "@/lib/storageManager";

const RedirectOnLoginContext = createContext<RedirectOnLoginProviderState | undefined>(undefined);

Expand All @@ -19,17 +23,18 @@ interface RedirectOnLoginProviderProps {
export const RedirectOnLoginProvider = ({ children }: RedirectOnLoginProviderProps) => {
// NB We use local storage to store requested url rather than SessionStorage as Chrome is unreliable at
// maintaining session keys for lifetime of tab.
const [requestedUrl, setRequestedUrlState] = useState<string | null>(() =>
localStorage.getItem(LocalStorageKeys.REQUESTED_URL)
);
const [requestedUrl, setRequestedUrlState] = useState<string | null>(() => getRequestedUrlFromLocalStorage());
const [loggingOut, setLoggingOut] = useState<boolean>(false);

const value = {
requestedUrl,
setRequestedUrl(url: string | null) {
setRequestedUrlState(url);
const key = LocalStorageKeys.REQUESTED_URL;
url === null ? localStorage.removeItem(key) : localStorage.setItem(key, url);
if (url === null) {
removeRequestedUrlFromLocalStorage();
} else {
setRequestedUrlInLocalStorage(url);
}
},
loggingOut,
setLoggingOut
Expand Down
30 changes: 30 additions & 0 deletions app/src/app/components/providers/RunnerConfigProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { ReactNode, createContext, useContext, useEffect, useState } from "react";
import { getRunnerConfigFromSessionStorage, setRunnerConfigInSessionStorage } from "@lib/storageManager";
import { ErrorComponent } from "../contents/common/ErrorComponent";
import { useGetRunnerEnabled } from "../header/hooks/useGetRunnerEnabled";

const RunnerConfigContext = createContext<boolean | null>(null);

export const useRunnerConfig = () => useContext(RunnerConfigContext);

interface RunnerConfigProviderProps {
children: ReactNode;
}

export const RunnerConfigProvider = ({ children }: RunnerConfigProviderProps) => {
const [runnerEnabled, setRunnerEnabled] = useState<boolean | null>(() => getRunnerConfigFromSessionStorage());
const { isRunnerEnabled, isLoading, error } = useGetRunnerEnabled(runnerEnabled);

useEffect(() => {
if (isRunnerEnabled !== undefined) {
setRunnerEnabled(isRunnerEnabled);
setRunnerConfigInSessionStorage(isRunnerEnabled);
}
}, [isRunnerEnabled]);

if (error) return <ErrorComponent message="failed to load runner config" error={error} />;

return (
<RunnerConfigContext.Provider value={isLoading ? null : runnerEnabled}>{children}</RunnerConfigContext.Provider>
);
};
Comment thread
david-mears-2 marked this conversation as resolved.
8 changes: 3 additions & 5 deletions app/src/app/components/providers/ThemeProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createContext, useContext, useEffect, useState } from "react";
import { LocalStorageKeys } from "@lib/types/LocalStorageKeys";
import { getThemeFromLocalStorage, setThemeInLocalStorage } from "@lib/storageManager";
import { Theme, ThemeProviderProps, ThemeProviderState } from "./types/ThemeTypes";
import { useGetBrandingConfig } from "./hooks/useGetBrandingConfig";
import { ErrorComponent } from "../contents/common/ErrorComponent";
Expand Down Expand Up @@ -35,9 +35,7 @@ const getAvailableThemes = (isLoading: boolean, brandingConfig: BrandingConfigur
export const ThemeProvider = ({ children, ...props }: ThemeProviderProps) => {
const { brandingConfig, isLoading, error } = useGetBrandingConfig();

const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(LocalStorageKeys.THEME) as Theme) || DEFAULT_THEME
);
const [theme, setTheme] = useState<Theme>(() => (getThemeFromLocalStorage() as Theme) || DEFAULT_THEME);

const availableThemes = getAvailableThemes(isLoading, brandingConfig);

Expand Down Expand Up @@ -70,7 +68,7 @@ export const ThemeProvider = ({ children, ...props }: ThemeProviderProps) => {
theme,
setTheme: (newTheme: Theme) => {
setTheme(newTheme);
localStorage.setItem(LocalStorageKeys.THEME, newTheme);
setThemeInLocalStorage(newTheme);
}
};

Expand Down
7 changes: 3 additions & 4 deletions app/src/app/components/providers/UserProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { jwtDecode } from "jwt-decode";
import { ReactNode, createContext, useContext, useState } from "react";
import { getUserFromLocalStorage } from "@lib/localStorageManager";
import { LocalStorageKeys } from "@lib/types/LocalStorageKeys";
import { getUserFromLocalStorage, removeUserFromLocalStorage, setUserInLocalStorage } from "@lib/storageManager";
import { PacketJwtPayload } from "@/types";
import { ErrorComponent } from "../contents/common/ErrorComponent";
import { useGetUserAuthorities } from "./hooks/useGetUserAuthorities";
Expand Down Expand Up @@ -37,11 +36,11 @@ export const UserProvider = ({ children }: UserProviderProps) => {
userName: jwtPayload.userName ?? ""
};
setUserState(user);
localStorage.setItem(LocalStorageKeys.USER, JSON.stringify(user));
setUserInLocalStorage(user);
},
removeUser() {
setUserState(null);
localStorage.removeItem(LocalStorageKeys.USER);
removeUserFromLocalStorage();
}
};

Expand Down
19 changes: 11 additions & 8 deletions app/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { AuthConfigProvider } from "@components/providers/AuthConfigProvider";
import { RunnerConfigProvider } from "@components/providers/RunnerConfigProvider";
import { ThemeProvider } from "@components/providers/ThemeProvider";
import { UserProvider } from "@components/providers/UserProvider";
import { RedirectOnLoginProvider } from "@components/providers/RedirectOnLoginProvider";
Expand All @@ -16,14 +17,16 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<BrandingProvider>
<ThemeProvider>
<AuthConfigProvider>
<UserProvider>
<RedirectOnLoginProvider>
<BrowserRouter basename={import.meta.env.BASE_URL}>
<Router />
<Toaster />
</BrowserRouter>
</RedirectOnLoginProvider>
</UserProvider>
<RunnerConfigProvider>
<UserProvider>
<RedirectOnLoginProvider>
<BrowserRouter basename={import.meta.env.BASE_URL}>
<Router />
<Toaster />
</BrowserRouter>
</RedirectOnLoginProvider>
</UserProvider>
</RunnerConfigProvider>
</AuthConfigProvider>
</ThemeProvider>
</BrandingProvider>
Expand Down
Loading
Loading