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
21 changes: 16 additions & 5 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,31 @@ on:
- develop-main
- develop-feature
paths-ignore:
- 'docs/**'
- "docs/**"

workflow_dispatch:

# Test jobs are separated for parallel execution and clearer failure identification
jobs:
# Backend and Lambda tests
test_backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

test:
- name: Run backend tests
shell: bash
run: |
make test_backend

runs-on: ubuntu-latest

# Frontend tests
test_frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Run test_run command
- name: Run frontend tests
shell: bash
run: |
make test_run
make test_frontend
47 changes: 25 additions & 22 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,49 +8,52 @@ define rm_unused_docker_containers
docker ps -a --filter "status=exited" --filter "name=$(1)" --format "{{.ID}}" | xargs --no-run-if-empty docker rm
endef

define cleanup_test_env
docker compose -f docker-compose.test.yml down
docker compose -f docker-compose.test.yml rm -f
@$(call rm_unused_docker_containers, $(1))
endef

define run_test_service
docker compose -f docker-compose.test.yml build $(1)
docker compose -f docker-compose.test.yml run $(1) $(2)
endef

PYTEST = poetry run pytest -s

.PHONY: test_run
test_run:
.PHONY: test_run_all
test_run_all:
# cleanup
docker compose -f docker-compose.test.yml down
docker compose -f docker-compose.test.yml rm -f
@$(call rm_unused_docker_containers, test_studio_backend)
# build/run
@$(call cleanup_test_env, test_studio_backend)
@$(call cleanup_test_env, test_studio_frontend)
# build containers once (performance optimization)
docker compose -f docker-compose.test.yml build test_studio_backend
docker compose -f docker-compose.test.yml build test_studio_frontend
docker compose -f docker-compose.test.yml run test_studio_backend $(PYTEST) -m "not heavier_processing"
# backend tests (studio/tests/app/ only)
docker compose -f docker-compose.test.yml run test_studio_backend $(PYTEST) studio/tests/app/ -m "not heavier_processing"
# frontend tests
docker compose -f docker-compose.test.yml run test_studio_frontend

.PHONY: test_backend
test_backend:
# cleanup
docker compose -f docker-compose.test.yml down
docker compose -f docker-compose.test.yml rm -f
@$(call rm_unused_docker_containers, test_studio_backend)
@$(call cleanup_test_env, test_studio_backend)
# build/run
docker compose -f docker-compose.test.yml build test_studio_backend
docker compose -f docker-compose.test.yml run test_studio_backend $(PYTEST) -m "not heavier_processing"
@$(call run_test_service, test_studio_backend, $(PYTEST) studio/tests/app/ -m "not heavier_processing")

.PHONY: test_backend_full
test_backend_full:
# cleanup
docker compose -f docker-compose.test.yml down
docker compose -f docker-compose.test.yml rm -f
@$(call rm_unused_docker_containers, test_studio_backend)
@$(call cleanup_test_env, test_studio_backend)
# build/run
docker compose -f docker-compose.test.yml build test_studio_backend
docker compose -f docker-compose.test.yml run test_studio_backend $(PYTEST)
@$(call run_test_service, test_studio_backend, $(PYTEST) studio/tests/app/)

.PHONY: test_frontend
test_frontend:
# cleanup
docker compose -f docker-compose.test.yml down
docker compose -f docker-compose.test.yml rm -f
@$(call rm_unused_docker_containers, test_studio_frontend)
@$(call cleanup_test_env, test_studio_frontend)
# build/run
docker compose -f docker-compose.test.yml build test_studio_frontend
docker compose -f docker-compose.test.yml run test_studio_frontend
@$(call run_test_service, test_studio_frontend)


############################## For Building ##############################
Expand Down
74 changes: 63 additions & 11 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { FC, useEffect } from "react"
import { FC, useEffect, useState } from "react"
import { useDispatch, useSelector } from "react-redux"
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"

import { isAxiosError } from "axios"
import { SnackbarProvider, SnackbarKey, useSnackbar } from "notistack"

import Close from "@mui/icons-material/Close"
import IconButton from "@mui/material/IconButton"

import BackendUnavailable from "components/common/BackendUnavailable"
import Loading from "components/common/Loading"
import Layout from "components/Layout"
import { RETRY_WAIT } from "const/Mode"
import { RETRY_MAX_COUNT, RETRY_WAIT, RETRY_WAIT_LONG } from "const/Mode"
import Account from "pages/Account"
import AccountDelete from "pages/AccountDelete"
import AccountManager from "pages/AccountManager"
Expand All @@ -25,25 +27,75 @@ import {
} from "store/slice/Standalone/StandaloneSeclector"
import { AppDispatch } from "store/store"

/**
* Returns whether the error from getModeStandalone should be retried.
*
* - Treat as "backend down" (retry): network/timeout errors and HTTP 5xx.
* - Treat as terminal (no retry): HTTP 4xx. Non-axios errors are retried for safety.
*/
const isRetryableBackendError = (error: unknown): boolean => {
if (!isAxiosError(error)) return true
if (!error.response) return true
const status = error.response.status
return status >= 500 && status < 600
}

const App: FC = () => {
const dispatch = useDispatch<AppDispatch>()
const isStandalone = useSelector(selectModeStandalone)
const loading = useSelector(selectLoading)
const getMode = () => {
dispatch(getModeStandalone())
.unwrap()
.catch(() => {
new Promise((resolve) => setTimeout(resolve, RETRY_WAIT)).then(() => {
getMode()
})
})
}
// Show the "backend unavailable" screen instead of the normal layout.
const [showBackendError, setShowBackendError] = useState(false)
// false = background polling has stopped (4xx); user must reload manually.
const [isRetrying, setIsRetrying] = useState(true)

useEffect(() => {
let cancelled = false
let timerId: ReturnType<typeof setTimeout> | undefined
let retryCount = 0

const getMode = () => {
dispatch(getModeStandalone())
.unwrap()
.then(() => {
if (cancelled) return
// Recovered: hide the error screen.
setShowBackendError(false)
setIsRetrying(true)
})
.catch((error: unknown) => {
if (cancelled) return
if (!isRetryableBackendError(error)) {
// Terminal error (4xx): stop polling, require manual reload.
setShowBackendError(true)
setIsRetrying(false)
return
}
retryCount += 1
const reachedMax = retryCount >= RETRY_MAX_COUNT
if (reachedMax) {
setShowBackendError(true)
}
const wait = reachedMax ? RETRY_WAIT_LONG : RETRY_WAIT
timerId = setTimeout(() => {
getMode()
}, wait)
})
}

getMode()

return () => {
cancelled = true
if (timerId !== undefined) clearTimeout(timerId)
}
//eslint-disable-next-line
}, [])

if (showBackendError) {
return <BackendUnavailable isRetrying={isRetrying} />
}

return loading ? (
<Loading loading={true} />
) : (
Expand Down
65 changes: 65 additions & 0 deletions frontend/src/components/common/BackendUnavailable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { FC } from "react"

import { Box, Stack, styled, Typography } from "@mui/material"

type Props = {
// true: background retries are running and the page will auto-recover.
// false: polling has stopped and the user must reload manually.
isRetrying: boolean
}

const BackendUnavailable: FC<Props> = ({ isRetrying }) => {
return (
<Wrapper>
<Content>
<Title>Cannot connect to the OptiNiSt server</Title>
<Message>
The server is currently unavailable. Please try again after a while.
</Message>
{isRetrying ? (
<SubMessage>
This page will automatically recover once the connection is
restored.
</SubMessage>
) : (
<SubMessage>Please reload this page to retry.</SubMessage>
)}
</Content>
</Wrapper>
)
}

const Wrapper = styled(Box)({
width: "100vw",
height: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 16,
})

const Content = styled(Stack)({
padding: 32,
boxShadow: "2px 1px 3px 1px rgba(0,0,0,0.1)",
borderRadius: 4,
maxWidth: 480,
textAlign: "center",
gap: 12,
})

const Title = styled(Typography)({
fontSize: 18,
fontWeight: 600,
})

const Message = styled(Typography)({
fontSize: 14,
color: "rgba(0, 0, 0, 0.75)",
})

const SubMessage = styled(Typography)({
fontSize: 12,
color: "rgba(0, 0, 0, 0.55)",
})

export default BackendUnavailable
4 changes: 4 additions & 0 deletions frontend/src/const/Mode.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
export const RETRY_WAIT = 5000
// Max retries at RETRY_WAIT before showing the backend-unavailable screen.
export const RETRY_MAX_COUNT = 5
// Retry interval used while the backend-unavailable screen is shown.
export const RETRY_WAIT_LONG = 30000
export const STANDALONE_WORKSPACE_ID = 1
Loading