Skip to content
GitLab
Projects
Groups
Snippets
/
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in
Toggle navigation
Menu
Open sidebar
math_tutor_dev
public_math_tutor
Commits
9640e4df
Commit
9640e4df
authored
Mar 05, 2026
by
Kantz
Browse files
health check anzeige
parent
b857e4c4
Changes
6
Show whitespace changes
Inline
Side-by-side
math-tutor/backend/app/api/health.py
View file @
9640e4df
...
@@ -74,9 +74,10 @@ def health() -> Dict[str, Any]:
...
@@ -74,9 +74,10 @@ def health() -> Dict[str, Any]:
"openai"
:
_check_openai
(),
"openai"
:
_check_openai
(),
"postgres"
:
_check_postgres
(),
"postgres"
:
_check_postgres
(),
}
}
overall
=
"ok"
required_statuses
=
{
"ok"
}
if
any
(
value
[
"status"
]
in
(
"error"
,
"unauthorized"
)
for
value
in
services
.
values
()):
overall
=
"ok"
if
all
(
overall
=
"degraded"
service
.
get
(
"status"
)
in
required_statuses
for
service
in
services
.
values
()
)
else
"degraded"
return
{
"status"
:
overall
,
"services"
:
services
}
return
{
"status"
:
overall
,
"services"
:
services
}
...
...
math-tutor/frontend/src/api/healthApi.ts
0 → 100644
View file @
9640e4df
export
type
HealthStatus
=
"
ok
"
|
"
degraded
"
|
"
error
"
|
"
unknown
"
;
export
type
HealthResponse
=
{
status
:
HealthStatus
;
services
?:
Record
<
string
,
{
status
?:
string
;
[
key
:
string
]:
unknown
}
>
;
};
const
normalizeHealthStatus
=
(
value
:
unknown
):
HealthStatus
=>
{
if
(
value
===
"
ok
"
||
value
===
"
degraded
"
||
value
===
"
error
"
)
{
return
value
;
}
return
"
unknown
"
;
};
export
async
function
fetchHealth
():
Promise
<
HealthResponse
>
{
const
response
=
await
fetch
(
"
/api/health
"
);
if
(
!
response
.
ok
)
{
throw
new
Error
(
`Health check failed:
${
response
.
status
}
`
);
}
const
payload
=
await
response
.
json
();
return
{
status
:
normalizeHealthStatus
(
payload
?.
status
),
services
:
payload
&&
typeof
payload
===
"
object
"
&&
payload
.
services
?
(
payload
.
services
as
HealthResponse
[
"
services
"
])
:
undefined
,
};
}
math-tutor/frontend/src/components/System/BackendHealthGate.tsx
0 → 100644
View file @
9640e4df
import
{
useCallback
,
useEffect
,
useMemo
,
useState
,
type
PropsWithChildren
}
from
"
react
"
;
import
{
fetchHealth
,
type
HealthResponse
}
from
"
../../api/healthApi
"
;
import
{
t
}
from
"
../../i18n
"
;
import
"
../../styles/theme.css
"
;
type
GateState
=
"
checking
"
|
"
healthy
"
|
"
unhealthy
"
;
const
HEALTH_POLL_INTERVAL_MS
=
5000
;
const
formatServiceStatus
=
(
health
:
HealthResponse
|
null
):
string
|
null
=>
{
if
(
!
health
?.
services
)
{
return
null
;
}
const
failing
=
Object
.
entries
(
health
.
services
)
.
filter
(([,
value
])
=>
{
const
status
=
String
(
value
?.
status
||
""
).
toLowerCase
();
return
status
&&
status
!==
"
ok
"
;
})
.
map
(([
service
,
value
])
=>
`
${
service
}
:
${
String
(
value
?.
status
||
"
unknown
"
)}
`
);
return
failing
.
length
?
failing
.
join
(
"
|
"
)
:
null
;
};
export
default
function
BackendHealthGate
({
children
}:
PropsWithChildren
)
{
const
[
gateState
,
setGateState
]
=
useState
<
GateState
>
(
"
checking
"
);
const
[
lastError
,
setLastError
]
=
useState
<
string
|
null
>
(
null
);
const
[
lastHealth
,
setLastHealth
]
=
useState
<
HealthResponse
|
null
>
(
null
);
const
[
isRetrying
,
setIsRetrying
]
=
useState
(
false
);
const
runHealthCheck
=
useCallback
(
async
()
=>
{
try
{
const
health
=
await
fetchHealth
();
setLastHealth
(
health
);
if
(
health
.
status
===
"
ok
"
)
{
setGateState
(
"
healthy
"
);
setLastError
(
null
);
}
else
{
setGateState
(
"
unhealthy
"
);
setLastError
(
`status=
${
health
.
status
}
`
);
}
}
catch
(
error
)
{
setGateState
(
"
unhealthy
"
);
setLastHealth
(
null
);
if
(
error
instanceof
Error
&&
error
.
message
)
{
setLastError
(
error
.
message
);
}
else
{
setLastError
(
"
network_error
"
);
}
}
},
[]);
useEffect
(()
=>
{
void
runHealthCheck
();
},
[
runHealthCheck
]);
useEffect
(()
=>
{
if
(
gateState
!==
"
unhealthy
"
)
{
return
;
}
const
timer
=
window
.
setInterval
(()
=>
{
void
runHealthCheck
();
},
HEALTH_POLL_INTERVAL_MS
);
return
()
=>
{
window
.
clearInterval
(
timer
);
};
},
[
gateState
,
runHealthCheck
]);
const
handleRetry
=
async
()
=>
{
setIsRetrying
(
true
);
try
{
await
runHealthCheck
();
}
finally
{
setIsRetrying
(
false
);
}
};
const
failureDetails
=
useMemo
(()
=>
{
const
serviceDetails
=
formatServiceStatus
(
lastHealth
);
if
(
serviceDetails
)
{
return
serviceDetails
;
}
return
lastError
;
},
[
lastError
,
lastHealth
]);
if
(
gateState
===
"
healthy
"
)
{
return
<>
{
children
}
</>;
}
return
(
<
div
className
=
"backend-health-shell"
>
<
header
className
=
"app-header backend-health-header"
>
<
div
className
=
"brand"
>
<
span
className
=
"brand-mark"
>
SUM
</
span
>
<
div
className
=
"brand-text"
>
<
div
className
=
"brand-title"
>
Math Tutor
</
div
>
<
div
className
=
"brand-subtitle"
>
{
t
(
"
prototypeWorkspace
"
)
}
</
div
>
</
div
>
</
div
>
<
div
className
=
"header-meta"
>
<
span
className
=
"pill"
>
Postgres + pgvector
</
span
>
<
span
className
=
"pill"
>
Python FastAPI
</
span
>
<
span
className
=
"pill"
>
Mathpix
</
span
>
</
div
>
</
header
>
<
main
className
=
"backend-health-main"
>
<
section
className
=
"backend-health-card"
>
{
gateState
===
"
checking
"
?
(
<
p
className
=
"backend-health-loading"
>
{
t
(
"
backendChecking
"
)
}
</
p
>
)
:
(
<>
<
h1
className
=
"backend-health-title"
>
{
t
(
"
backendUnavailableTitle
"
)
}
</
h1
>
<
p
className
=
"backend-health-text"
>
{
t
(
"
backendUnavailableBody
"
)
}
</
p
>
{
failureDetails
?
(
<
p
className
=
"backend-health-detail"
>
{
t
(
"
lastCheckFailed
"
,
{
detail
:
failureDetails
})
}
</
p
>
)
:
null
}
<
button
type
=
"button"
className
=
"btn primary"
onClick
=
{
handleRetry
}
disabled
=
{
isRetrying
}
>
{
isRetrying
?
t
(
"
loading
"
)
:
t
(
"
retryConnection
"
)
}
</
button
>
</>
)
}
</
section
>
</
main
>
</
div
>
);
}
math-tutor/frontend/src/i18n.ts
View file @
9640e4df
...
@@ -62,6 +62,12 @@
...
@@ -62,6 +62,12 @@
changeTaskArea
:
"
Change Task Area
"
,
changeTaskArea
:
"
Change Task Area
"
,
previousTask
:
"
Previous Task
"
,
previousTask
:
"
Previous Task
"
,
nextTask
:
"
Next Task
"
,
nextTask
:
"
Next Task
"
,
backendChecking
:
"
Checking backend availability...
"
,
backendUnavailableTitle
:
"
Backend not reachable
"
,
backendUnavailableBody
:
"
The tutor backend is currently unavailable or still starting up. We will keep trying automatically.
"
,
retryConnection
:
"
Retry now
"
,
lastCheckFailed
:
"
Last check: {detail}
"
,
},
},
de
:
{
de
:
{
chats
:
"
Chats
"
,
chats
:
"
Chats
"
,
...
@@ -131,6 +137,12 @@
...
@@ -131,6 +137,12 @@
changeTaskArea
:
"
Aufgabengebiet ändern
"
,
changeTaskArea
:
"
Aufgabengebiet ändern
"
,
previousTask
:
"
Vorherige Aufgabe
"
,
previousTask
:
"
Vorherige Aufgabe
"
,
nextTask
:
"
Nächste Aufgabe
"
,
nextTask
:
"
Nächste Aufgabe
"
,
backendChecking
:
"
Backend-Verbindung wird geprüft...
"
,
backendUnavailableTitle
:
"
Backend nicht erreichbar
"
,
backendUnavailableBody
:
"
Das Tutor-Backend ist aktuell nicht erreichbar oder startet noch. Wir versuchen es automatisch weiter.
"
,
retryConnection
:
"
Erneut prüfen
"
,
lastCheckFailed
:
"
Letzte Prüfung: {detail}
"
,
},
},
}
as
const
;
}
as
const
;
...
...
math-tutor/frontend/src/main.tsx
View file @
9640e4df
...
@@ -3,11 +3,14 @@ import { createRoot } from "react-dom/client";
...
@@ -3,11 +3,14 @@ import { createRoot } from "react-dom/client";
import
{
BrowserRouter
}
from
"
react-router-dom
"
;
import
{
BrowserRouter
}
from
"
react-router-dom
"
;
import
"
./index.css
"
;
import
"
./index.css
"
;
import
App
from
"
./pages/App.tsx
"
;
import
App
from
"
./pages/App.tsx
"
;
import
BackendHealthGate
from
"
./components/System/BackendHealthGate.tsx
"
;
createRoot
(
document
.
getElementById
(
"
root
"
)
!
).
render
(
createRoot
(
document
.
getElementById
(
"
root
"
)
!
).
render
(
<
StrictMode
>
<
StrictMode
>
<
BrowserRouter
>
<
BrowserRouter
>
<
BackendHealthGate
>
<
App
/>
<
App
/>
</
BackendHealthGate
>
</
BrowserRouter
>
</
BrowserRouter
>
</
StrictMode
>
</
StrictMode
>
);
);
math-tutor/frontend/src/styles/theme.css
View file @
9640e4df
...
@@ -704,6 +704,61 @@ body {
...
@@ -704,6 +704,61 @@ body {
color
:
#6f675d
;
color
:
#6f675d
;
}
}
.backend-health-shell
{
min-height
:
100vh
;
padding
:
24px
;
background
:
radial-gradient
(
circle
at
top
left
,
#fff7e6
,
#f4f1ea
);
display
:
flex
;
flex-direction
:
column
;
}
.backend-health-header
{
margin-bottom
:
24px
;
}
.backend-health-main
{
flex
:
1
;
display
:
grid
;
place-items
:
center
;
}
.backend-health-card
{
width
:
min
(
620px
,
100%
);
background
:
#fef9f0
;
border
:
1px
solid
#d8d1c4
;
border-radius
:
16px
;
padding
:
18px
;
display
:
flex
;
flex-direction
:
column
;
gap
:
10px
;
}
.backend-health-title
{
margin
:
0
;
font-size
:
20px
;
}
.backend-health-text
{
margin
:
0
;
color
:
#6f675d
;
}
.backend-health-loading
{
margin
:
0
;
color
:
#6f675d
;
}
.backend-health-detail
{
margin
:
0
;
padding
:
8px
10px
;
border-radius
:
10px
;
background
:
#f3d9d6
;
color
:
#8a3b2f
;
border
:
1px
solid
#e2b4ae
;
font-size
:
12px
;
overflow-wrap
:
anywhere
;
}
.task-select-main
{
.task-select-main
{
flex
:
1
;
flex
:
1
;
display
:
grid
;
display
:
grid
;
...
@@ -769,6 +824,14 @@ body {
...
@@ -769,6 +824,14 @@ body {
}
}
@media
(
max-width
:
900px
)
{
@media
(
max-width
:
900px
)
{
.backend-health-shell
{
padding
:
16px
;
}
.backend-health-card
{
width
:
100%
;
}
.task-select-controls
{
.task-select-controls
{
grid-template-columns
:
1
fr
;
grid-template-columns
:
1
fr
;
}
}
...
...
Write
Preview
Supports
Markdown
0%
Try again
or
attach a new file
.
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment