Serve the operator page and /ask from one origin
api.yokogawa.tech has a public A record but no pinpoint record on the DC, so it does not resolve from inside the VNet at all. The browser called it by hostname, which means an operator on cicore1 would have loaded the page and had every question fail on DNS - the exact gap Phase 7's gate exists to catch, and one an engineer's laptop cannot see. Caddy now routes /ask under ai.yokogawa.tech to ai-api, inside a route block so import authelia still runs first: forward_auth sorts after handle in the default directive order, and outside a route the handles would be terminal and the gate would never run. Only /ask is routed - the Phase 9 publisher rule is scoped to api.yokogawa.tech and a wider route here would leave it inert. Also fixes the fallback it replaces. The build arg defaults to "", and `?? "https://api.yokogawa.tech"` does not catch an empty string, so the documented real-deployment build resolved the API base to "" and posted /ask at ai-web, which 404s it. verify.sh and deploy.sh now check both the route and whether the built bundle carries the hostname. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
bff4dec49a
commit
76af3156fa
9 changed files with 136 additions and 44 deletions
|
|
@ -195,6 +195,8 @@ ai.yokogawa.tech {
|
||||||
|
|
||||||
**Azure hairpin:** LAN hosts cannot reach the VM's public IP from inside the VNet. For an operator on `cicore1` to reach `ai.yokogawa.tech` by hostname, the DC needs a pinpoint record → `10.0.0.17`, the same treatment `influx.yokogawa.tech` already has. **Raise this early** — it is a dependency on someone else and it will not surface until Phase 7.
|
**Azure hairpin:** LAN hosts cannot reach the VM's public IP from inside the VNet. For an operator on `cicore1` to reach `ai.yokogawa.tech` by hostname, the DC needs a pinpoint record → `10.0.0.17`, the same treatment `influx.yokogawa.tech` already has. **Raise this early** — it is a dependency on someone else and it will not surface until Phase 7.
|
||||||
|
|
||||||
|
**Done 2026-08-27** for `ai.yokogawa.tech` only. `api` and `cube` have public A records but **no pinpoint record**, so they do not resolve inside the VNet at all. The operator UI therefore does not call `api.yokogawa.tech`: Caddy routes `/ask` under `ai.yokogawa.tech` to `ai-api` and the page is same-origin. Only `/ask` — the Phase 9 publisher rule is scoped to `api.yokogawa.tech`, and a wider route there would make it inert. See `caddy/ai-routes.caddy`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Working around the pending `imh`
|
## 6. Working around the pending `imh`
|
||||||
|
|
@ -595,13 +597,14 @@ local time changes the answer.
|
||||||
**Tasks**
|
**Tasks**
|
||||||
1. React + Vite: question box, answer pane, **"show working"** panel (class, query, row count, citations with revision and effective date).
|
1. React + Vite: question box, answer pane, **"show working"** panel (class, query, row count, citations with revision and effective date).
|
||||||
2. Procedural and Advisory answers carry a visible scope banner stating what the assistant did *not* do. Operators must not infer this from tone.
|
2. Procedural and Advisory answers carry a visible scope banner stating what the assistant did *not* do. Operators must not infer this from tone.
|
||||||
3. `ai-web` behind Caddy + Authelia at `ai.yokogawa.tech`.
|
3. `ai-web` behind Caddy + Authelia at `ai.yokogawa.tech`, with `/ask` routed to `ai-api` under the same hostname so the page is same-origin. Build `ai-web` with `VITE_API_BASE` empty; a bundle carrying `api.yokogawa.tech` works from outside and fails on every control-room PC.
|
||||||
4. **Pinpoint DNS on the DC** → `10.0.0.17` so `cicore1` can resolve it (Azure hairpin). Ask Dan.
|
4. ~~**Pinpoint DNS on the DC** → `10.0.0.17` so `cicore1` can resolve it (Azure hairpin). Ask Dan.~~ **Done 2026-08-27.**
|
||||||
5. Confirm the operator's AD account is a **direct** member of `HTTPS_UserAccess` and Duo-enrolled.
|
5. Confirm the operator's AD account is a **direct** member of `HTTPS_UserAccess` and Duo-enrolled.
|
||||||
|
|
||||||
**Gate**
|
**Gate**
|
||||||
- [ ] `curl -sI https://ai.yokogawa.tech` → 302 to the auth portal
|
- [ ] `curl -sI https://ai.yokogawa.tech` → 302 to the auth portal
|
||||||
- [ ] An operator on `cicore1` reaches the UI **by hostname** and gets an answer end to end
|
- [ ] `curl -s -o /dev/null -w '%{http_code}' -X POST https://ai.yokogawa.tech/ask` → 302, **not 404**. A 404 is ai-web answering, which means the `/ask` route is missing and every question will fail.
|
||||||
|
- [ ] An operator on `cicore1` reaches the UI **by hostname** and gets an answer end to end — this is the check that catches a name resolvable from outside and not from inside. Doing it from an engineer's laptop proves nothing about it.
|
||||||
- [ ] Citations show document number, revision and effective date
|
- [ ] Citations show document number, revision and effective date
|
||||||
- [ ] The scope banner appears on every Procedural and Advisory answer
|
- [ ] The scope banner appears on every Procedural and Advisory answer
|
||||||
|
|
||||||
|
|
|
||||||
20
README.md
20
README.md
|
|
@ -231,12 +231,20 @@ Phase 5"; eval cases `H26` and `H27` fail until they are settled.
|
||||||
./scripts/verify.sh
|
./scripts/verify.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Each prints the manual DNS/Caddy/Authelia steps. Phase 7 also needs a
|
Each prints the manual DNS/Caddy/Authelia steps.
|
||||||
**pinpoint DNS record on the DC** → `10.0.0.17` so an operator on `cicore1` can
|
|
||||||
resolve `ai.yokogawa.tech` — Azure hairpin means LAN hosts cannot reach the
|
**DNS is done as of 27 August 2026**: `ai`, `api` and `cube` all resolve to
|
||||||
VM's public IP from inside the VNet. `influx.yokogawa.tech` already has this
|
`20.211.144.151`, and the DC carries the pinpoint record `ai.yokogawa.tech` →
|
||||||
treatment. **Raise it early**; it depends on someone else and will not surface
|
`10.0.0.17` that the Azure hairpin requires. The Caddyfile blocks and Authelia
|
||||||
until you try it.
|
rules for the three names still have to be applied on the host — until they
|
||||||
|
are, the names resolve and nothing answers.
|
||||||
|
|
||||||
|
**`api.yokogawa.tech` has no pinpoint record and does not resolve inside the
|
||||||
|
VNet.** That is why the Phase 7 Caddy block routes `/ask` under
|
||||||
|
`ai.yokogawa.tech` to `ai-api` and the page is same-origin: a cross-origin
|
||||||
|
build loads on a control-room PC and then fails every question on DNS. Only
|
||||||
|
`/ask` is routed there — see the warning in
|
||||||
|
[`caddy/ai-routes.caddy`](caddy/ai-routes.caddy) before widening it.
|
||||||
|
|
||||||
### 7. Phase 8 — validate
|
### 7. Phase 8 — validate
|
||||||
|
|
||||||
|
|
|
||||||
26
api/main.py
26
api/main.py
|
|
@ -41,19 +41,23 @@ app = FastAPI(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# The browser reaches the API by its own public hostname, so a same-site
|
# The operator's page is same-origin as of 2026-08-27, so it makes no
|
||||||
# origin is all that is ever needed.
|
# cross-origin call and none of this applies to it. CORS is kept for the
|
||||||
|
# tunnelled build and for anything calling api.yokogawa.tech directly.
|
||||||
#
|
#
|
||||||
# NO_LLM_STUB additionally allows a tunnelled origin, because the public
|
# NO_LLM_STUB additionally allows a tunnelled origin, because the whole chain
|
||||||
# hostnames do not resolve yet and the whole chain has to be exercisable
|
# has to be exercisable without Caddy and Authelia. It is deliberately tied to
|
||||||
# without them. It is deliberately tied to the stub flag so it disappears when
|
# the stub flag so it disappears when the flag does.
|
||||||
# the flag does.
|
|
||||||
#
|
#
|
||||||
# The alternative - proxying /api through ai-web's nginx to make the page
|
# Earlier this file recorded the same-origin option as rejected, on the grounds
|
||||||
# same-origin - was rejected: it creates a second route to the API that does
|
# that a second route to the API bypasses the api.yokogawa.tech Caddy block,
|
||||||
# not pass through the api.yokogawa.tech Caddy block, and that block is where
|
# where the Phase 9 publisher rule for ^/docs/.* lives. That objection was to
|
||||||
# the Phase 9 publisher rule for ^/docs/.* lives. A convenience path around an
|
# proxying the WHOLE API through ai-web's nginx, and it still stands. What was
|
||||||
# authorisation rule is how the rule stops meaning anything.
|
# built instead is narrower: Caddy routes ONLY /ask under ai.yokogawa.tech,
|
||||||
|
# through the same `import authelia` gate; /docs has no route there and 404s.
|
||||||
|
# The reason it had to change is that api.yokogawa.tech has no pinpoint DNS
|
||||||
|
# record, so an operator on cicore1 cannot resolve it at all. See
|
||||||
|
# caddy/ai-routes.caddy, which carries the do-not-widen warning.
|
||||||
_ORIGINS = ["https://ai.yokogawa.tech"]
|
_ORIGINS = ["https://ai.yokogawa.tech"]
|
||||||
if settings().no_llm_stub:
|
if settings().no_llm_stub:
|
||||||
_ORIGINS.append("http://localhost:8080")
|
_ORIGINS.append("http://localhost:8080")
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,12 @@
|
||||||
# DNS is not managed on this host. Each hostname needs an A record ->
|
# DNS is not managed on this host. Each hostname needs an A record ->
|
||||||
# 20.211.144.151 before Caddy can issue a certificate. Ask Dan.
|
# 20.211.144.151 before Caddy can issue a certificate. Ask Dan.
|
||||||
#
|
#
|
||||||
# Azure hairpin: LAN hosts cannot reach the VM public IP from inside the VNet.
|
# DONE 2026-08-27: ai, api and cube .yokogawa.tech all resolve to
|
||||||
# For an operator on cicore1 to reach ai.yokogawa.tech by hostname, the DC needs
|
# 20.211.144.151, and the DC carries the pinpoint record
|
||||||
# a pinpoint record -> 10.0.0.17, the same treatment influx.yokogawa.tech has.
|
# ai.yokogawa.tech -> 10.0.0.17 for the Azure hairpin (same treatment as
|
||||||
# Raise this early - it is a dependency on someone else and will not surface
|
# influx.yokogawa.tech). api and cube have NO pinpoint record and are
|
||||||
# until Phase 7.
|
# unresolvable from inside the VNet - which is why /ask is routed under
|
||||||
|
# ai.yokogawa.tech below rather than called cross-origin.
|
||||||
#
|
#
|
||||||
# Add each block at the phase that needs it, not all at once. A hostname with a
|
# Add each block at the phase that needs it, not all at once. A hostname with a
|
||||||
# Caddyfile block and no Authelia rule is a hole.
|
# Caddyfile block and no Authelia rule is a hole.
|
||||||
|
|
@ -56,6 +57,16 @@ api.yokogawa.tech {
|
||||||
# edit the shared snippet to add it - every other service on the host
|
# edit the shared snippet to add it - every other service on the host
|
||||||
# imports it, and this is not the change to make on their behalf.
|
# imports it, and this is not the change to make on their behalf.
|
||||||
#
|
#
|
||||||
|
# CHECKED 2026-08-27: the snippet on lin001 is
|
||||||
|
# forward_auth authelia:9091 {
|
||||||
|
# uri /api/authz/forward-auth
|
||||||
|
# copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
|
||||||
|
# }
|
||||||
|
# It already copies all four headers, so Phase 9 needs NO change here -
|
||||||
|
# only request_body if 50MB uploads are wanted. Note the uri differs from
|
||||||
|
# the one written below. Re-check before Phase 9 rather than trusting this
|
||||||
|
# line; it is a file other people edit.
|
||||||
|
#
|
||||||
# If the snippet does not copy them, replace the Phase 6 block with:
|
# If the snippet does not copy them, replace the Phase 6 block with:
|
||||||
#
|
#
|
||||||
# api.yokogawa.tech {
|
# api.yokogawa.tech {
|
||||||
|
|
@ -81,7 +92,34 @@ api.yokogawa.tech {
|
||||||
# matcher would be a third place to keep in step, and the first to be forgotten.
|
# matcher would be a third place to keep in step, and the first to be forgotten.
|
||||||
|
|
||||||
# --- Phase 7 -----------------------------------------------------------------
|
# --- Phase 7 -----------------------------------------------------------------
|
||||||
|
# /ask is served under THIS hostname as well as under api.yokogawa.tech, so the
|
||||||
|
# operator's page is same-origin. That is not a convenience: LAN hosts resolve
|
||||||
|
# yokogawa.tech names through the DC, which holds pinpoint records only, and
|
||||||
|
# there is one for ai.yokogawa.tech and none for api.yokogawa.tech. Cross-origin
|
||||||
|
# means an operator on cicore1 loads the page and every question fails on DNS.
|
||||||
|
#
|
||||||
|
# ONLY /ask. Do NOT widen this matcher.
|
||||||
|
# - The Phase 9 publisher rule is `domain: api.yokogawa.tech` +
|
||||||
|
# `resources: ^/docs/.*` (authelia/access-rules.md). A /docs route under this
|
||||||
|
# hostname would not match it, and approving a procedure revision would stop
|
||||||
|
# being gated on AI_DocPublishers. ai-api re-checks Remote-Groups itself, so
|
||||||
|
# it would not actually fail open - but the rule would be inert and nobody
|
||||||
|
# would know.
|
||||||
|
# - `/docs*` under this hostname hits the second handle and is served by
|
||||||
|
# ai-web, which 404s it. That is the intended outcome. Leave it that way.
|
||||||
|
#
|
||||||
|
# `route` is required. `import authelia` expands to forward_auth, which sorts
|
||||||
|
# AFTER handle in Caddy's default directive order - outside a route block the
|
||||||
|
# handles would be terminal and the gate would never run, silently serving the
|
||||||
|
# UI to anyone. Inside `route`, directives run in written order.
|
||||||
ai.yokogawa.tech {
|
ai.yokogawa.tech {
|
||||||
|
route {
|
||||||
import authelia
|
import authelia
|
||||||
|
handle /ask* {
|
||||||
|
reverse_proxy ai-api:8000
|
||||||
|
}
|
||||||
|
handle {
|
||||||
reverse_proxy ai-web:80
|
reverse_proxy ai-web:80
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -171,9 +171,10 @@ services:
|
||||||
context: /home/azureuser/ai/web
|
context: /home/azureuser/ai/web
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
# Empty in a real deployment: the browser reaches the API at its own
|
# Empty in a real deployment: the page is same-origin, because Caddy
|
||||||
# public hostname through Caddy. Set only for a tunnelled demo build,
|
# routes /ask under ai.yokogawa.tech to ai-api. Set only for a
|
||||||
# which has no Caddy and no Authelia in front of it.
|
# tunnelled demo build, which has no Caddy and no Authelia in front
|
||||||
|
# of it.
|
||||||
VITE_API_BASE: "${VITE_API_BASE:-}"
|
VITE_API_BASE: "${VITE_API_BASE:-}"
|
||||||
image: yau/ai-web:local
|
image: yau/ai-web:local
|
||||||
container_name: ai-web
|
container_name: ai-web
|
||||||
|
|
|
||||||
|
|
@ -147,8 +147,16 @@ deploy_web() {
|
||||||
say "Building and starting ai-web"
|
say "Building and starting ai-web"
|
||||||
docker compose -f "$COMPOSE" up -d --build ai-web
|
docker compose -f "$COMPOSE" up -d --build ai-web
|
||||||
manual_steps "ai.yokogawa.tech"
|
manual_steps "ai.yokogawa.tech"
|
||||||
warn "Azure hairpin: cicore1 cannot reach the public IP from inside the VNet."
|
warn "The ai.yokogawa.tech Caddy block routes /ask to ai-api so the page is"
|
||||||
warn "The DC needs a pinpoint record ai.yokogawa.tech -> 10.0.0.17. Ask Dan."
|
warn "same-origin: api.yokogawa.tech has NO pinpoint record and does not"
|
||||||
|
warn "resolve inside the VNet. Do not widen that route past /ask - the"
|
||||||
|
warn "Phase 9 publisher rule is scoped to api.yokogawa.tech. See"
|
||||||
|
warn "caddy/ai-routes.caddy."
|
||||||
|
# A bundle built with VITE_API_BASE set to a public hostname works from
|
||||||
|
# outside and fails on every control-room PC. Catch it here, not on the day.
|
||||||
|
if docker exec ai-web sh -c 'grep -rqs "api\.yokogawa\.tech" /usr/share/nginx/html' 2>/dev/null; then
|
||||||
|
warn "THIS BUILD hard-codes api.yokogawa.tech. Rebuild with VITE_API_BASE empty."
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- The parts a human must do -----------------------------------------------
|
# --- The parts a human must do -----------------------------------------------
|
||||||
|
|
@ -161,6 +169,8 @@ manual_steps() {
|
||||||
|
|
||||||
1. DNS A record -> 20.211.144.151. Ask Dan; DNS is not managed here.
|
1. DNS A record -> 20.211.144.151. Ask Dan; DNS is not managed here.
|
||||||
Caddy cannot issue a certificate without it.
|
Caddy cannot issue a certificate without it.
|
||||||
|
DONE 2026-08-27 for ai, api and cube, plus the DC pinpoint record
|
||||||
|
ai.yokogawa.tech -> 10.0.0.17. Nothing to do for those three.
|
||||||
|
|
||||||
2. Append the block from caddy/ai-routes.caddy to ~/Caddyfile.
|
2. Append the block from caddy/ai-routes.caddy to ~/Caddyfile.
|
||||||
Keep 'import authelia'. Omitting it silently makes the service public.
|
Keep 'import authelia'. Omitting it silently makes the service public.
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,27 @@ for host in lf.yokogawa.tech cube.yokogawa.tech api.yokogawa.tech ai.yokogawa.te
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
head_ "The operator page and the API share an origin"
|
||||||
|
# The browser posts to /ask on ai.yokogawa.tech. If the route is missing, ai-web
|
||||||
|
# serves the SPA's 404 and every question fails - with the UI itself looking
|
||||||
|
# perfectly healthy. Unauthenticated this must be the auth portal redirect, NOT
|
||||||
|
# a 404 from ai-web.
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "https://ai.yokogawa.tech/ask" -H 'Content-Type: application/json' -d '{}' --max-time 10 || echo "000")
|
||||||
|
case "$code" in
|
||||||
|
302|303) ok "ai.yokogawa.tech/ask -> $code (routed to ai-api, gated)" ;;
|
||||||
|
404) bad "ai.yokogawa.tech/ask -> 404 - the /ask route is missing from the Caddy block; ai-web is answering" ;;
|
||||||
|
000) bad "ai.yokogawa.tech/ask unreachable" ;;
|
||||||
|
*) bad "ai.yokogawa.tech/ask -> $code" ;;
|
||||||
|
esac
|
||||||
|
# api.yokogawa.tech has no pinpoint DNS record, so it is unresolvable from
|
||||||
|
# inside the VNet. A bundle that hard-codes it loads fine here and fails on a
|
||||||
|
# control-room PC. Check what was actually built into the image.
|
||||||
|
if docker exec ai-web sh -c 'grep -rqs "api\.yokogawa\.tech" /usr/share/nginx/html' 2>/dev/null; then
|
||||||
|
bad "the ai-web bundle hard-codes api.yokogawa.tech - cicore1 cannot resolve it; rebuild with VITE_API_BASE empty"
|
||||||
|
else
|
||||||
|
ok "ai-web bundle carries no cross-origin API hostname"
|
||||||
|
fi
|
||||||
|
|
||||||
head_ "agent_ro is read-only"
|
head_ "agent_ro is read-only"
|
||||||
if docker exec pg-ai psql -U agent_ro -d plant -tAc 'SELECT count(*) FROM equipment' >/dev/null 2>&1; then
|
if docker exec pg-ai psql -U agent_ro -d plant -tAc 'SELECT count(*) FROM equipment' >/dev/null 2>&1; then
|
||||||
ok "agent_ro can SELECT"
|
ok "agent_ro can SELECT"
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,9 @@ COPY package.json ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Where the browser should send /ask. Empty means the built-in default, which
|
# Where the browser should send /ask. Empty - the default - means same-origin:
|
||||||
# is the public hostname. A tunnelled demo build overrides it:
|
# Caddy routes /ask under ai.yokogawa.tech to ai-api, so no hostname is baked
|
||||||
|
# into the bundle at all. A tunnelled demo build overrides it:
|
||||||
# --build-arg VITE_API_BASE=http://localhost:8001
|
# --build-arg VITE_API_BASE=http://localhost:8001
|
||||||
# Vite inlines this at BUILD time, so a change needs a rebuild, not a restart.
|
# Vite inlines this at BUILD time, so a change needs a rebuild, not a restart.
|
||||||
ARG VITE_API_BASE=""
|
ARG VITE_API_BASE=""
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,25 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { AnswerBody, AskResponse, Citation } from "./types";
|
import type { AnswerBody, AskResponse, Citation } from "./types";
|
||||||
|
|
||||||
// Same-origin in dev, otherwise VITE_API_BASE at build time, falling back to
|
// Same-origin, always, unless a build explicitly overrides it. Caddy routes
|
||||||
// the public hostname. The browser carries the Authelia session cookie either
|
// /ask under ai.yokogawa.tech to ai-api (caddy/ai-routes.caddy), so the page
|
||||||
// way; there is no token handling in this app because Authelia authenticates
|
// and the API share an origin and the browser attaches the Authelia session
|
||||||
// at the edge.
|
// cookie with no cross-origin handling at all.
|
||||||
//
|
//
|
||||||
// VITE_API_BASE exists because the public hostname does not resolve yet. A
|
// It is same-origin because an operator on cicore1 CANNOT resolve
|
||||||
// tunnelled build points it at http://localhost:8001 so the whole chain can be
|
// api.yokogawa.tech: LAN hosts resolve through the DC, which holds a pinpoint
|
||||||
// exercised before DNS, Caddy and Authelia are in place - which also means a
|
// record for ai.yokogawa.tech and none for api. A cross-origin build loads and
|
||||||
// tunnelled build has NO AUTHENTICATION in front of it. It is reachable only
|
// then fails every question on DNS.
|
||||||
// through an SSH tunnel from one machine, and it is not a deployment.
|
//
|
||||||
|
// VITE_API_BASE remains for a tunnelled build, which points it at
|
||||||
|
// http://localhost:8001 - that has NO AUTHENTICATION in front of it, is
|
||||||
|
// reachable only through an SSH tunnel from one machine, and is not a
|
||||||
|
// deployment. The empty-string check is deliberate: the build arg defaults to
|
||||||
|
// "" (web/Dockerfile), and `??` does not catch an empty string - it read as a
|
||||||
|
// valid base and sent /ask to the origin with no route for it.
|
||||||
const API = import.meta.env.DEV
|
const API = import.meta.env.DEV
|
||||||
? "/api"
|
? "/api"
|
||||||
: (import.meta.env.VITE_API_BASE ?? "https://api.yokogawa.tech");
|
: (import.meta.env.VITE_API_BASE || "");
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [question, setQuestion] = useState("");
|
const [question, setQuestion] = useState("");
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue