5 Commits

Author SHA1 Message Date
danamir
24cce8ea71 Show the question bank as a table with a stable, right-aligned actions column
All checks were successful
CI/CD / build-test-deploy (pull_request) Successful in 1m39s
The old list showed type, text, and points inline via flexbox, with no
correct-answer info at all, and unstyled buttons that shifted position
depending on question text length. A table with a shrink-to-fit
actions column (text/kind/answer/points first, buttons last) fixes
both: correct answers are now visible, and button position no longer
depends on text length.
2026-08-09 19:03:40 +03:00
danamir
b7177c455e Fix CI/CD deploy targeting the wrong compose project
All checks were successful
CI/CD / build-test-deploy (push) Successful in 29s
COMPOSE_PROJECT_NAME was "ruvdstests" but the live stack was actually
deployed under "ruvdstest" (no trailing s) — Compose treated it as an
unrelated project and spun up a whole second stack from scratch,
colliding with the real one's host port and aborting mid-deploy
instead of updating the running containers in place.
2026-08-09 01:29:10 +03:00
38ef683b69 Merge pull request 'feature/teacher-quiz-form' (#1) from feature/teacher-quiz-form into master
Some checks failed
CI/CD / build-test-deploy (push) Failing after 31s
Reviewed-on: #1
2026-08-08 22:15:02 +00:00
danamir
8c46ef1972 Wire JWT_SECRET/POSTGRES_PASSWORD through Gitea Actions secrets
All checks were successful
CI/CD / build-test-deploy (pull_request) Successful in 4m43s
The job checks out into the runner's own workspace, not
/opt/ruvdstests where DEPLOY.md has the human create a .env file — so
docker compose here had no .env to read JWT_SECRET from and failed
outright. POSTGRES_PASSWORD would have silently fallen back to the
compose file's devpassword default instead of erroring, which on
Deploy would have broken auth against the already-initialized pgdata
volume.
2026-08-09 01:04:13 +03:00
danamir
0a1407719e Install Node.js before Checkout in the CI/CD workflow
Some checks failed
CI/CD / build-test-deploy (pull_request) Failing after 53s
Host-mode jobs execute directly on the bare runner container rather
than a per-job container, and actions/checkout is a JS action — it
needs `node` on PATH to run at all, which the runner image doesn't
ship. Failed every run with "Cannot find: node in PATH" before any
other step got a chance to install anything.
2026-08-09 01:00:12 +03:00
4 changed files with 132 additions and 36 deletions

View File

@@ -9,14 +9,38 @@ on:
# Pinned so every run targets the same project regardless of the runner's # Pinned so every run targets the same project regardless of the runner's
# checkout path — otherwise `docker compose` would derive the project name # checkout path — otherwise `docker compose` would derive the project name
# from the checkout directory, potentially spinning up a second stack and # from the checkout directory, potentially spinning up a second stack and
# losing the `pgdata` volume instead of updating the running one. # losing the `pgdata` volume instead of updating the running one. Must be
# `ruvdstest` (no trailing "s") to match the project name the live stack was
# actually deployed under — a one-letter mismatch here previously made
# Compose treat it as an unrelated project and spin up a whole second stack
# from scratch instead of recognizing and updating the running containers.
#
# JWT_SECRET/POSTGRES_PASSWORD come from Gitea's own Actions secrets store
# rather than the `.env` file DEPLOY.md has the human create in
# `/opt/ruvdstests` — the job checks out into the runner's own workspace, not
# that directory, so there's no `.env` for `docker compose` to read here.
# Must match the values already in that `.env` file: on `Deploy` this
# `docker compose up -d` targets the same running project (via
# COMPOSE_PROJECT_NAME above), and a different POSTGRES_PASSWORD than what
# the live `pgdata` volume was initialized with breaks the DB connection.
env: env:
COMPOSE_PROJECT_NAME: ruvdstests COMPOSE_PROJECT_NAME: ruvdstest
JWT_SECRET: ${{ secrets.JWT_SECRET }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
jobs: jobs:
build-test-deploy: build-test-deploy:
runs-on: host runs-on: host
steps: steps:
# `runs-on: host` runs every step directly on the bare runner
# container instead of spinning up a fresh container per job, and
# `actions/checkout` below is a JS action — it needs a `node` binary
# on PATH to run at all, which this image doesn't ship. Has to come
# before Checkout, since Checkout itself is what fails without it.
- name: Install Node.js
shell: bash
run: apk add --no-cache nodejs
- name: Checkout - name: Checkout
uses: https://github.com/actions/checkout@v4 uses: https://github.com/actions/checkout@v4

View File

@@ -37,13 +37,25 @@ docker run -d --name gitea-ci-runner --restart unless-stopped \
`host:host` — раннер выполняет джобы напрямую в своём собственном контейнере (никаких вложенных `host:host` — раннер выполняет джобы напрямую в своём собственном контейнере (никаких вложенных
контейнеров на каждый джоб), у него смонтирован docker.sock хоста — поэтому `docker compose` контейнеров на каждый джоб), у него смонтирован docker.sock хоста — поэтому `docker compose`
внутри джоба управляет реальными контейнерами на этой машине. Джоб сам ставит себе `docker-cli` и внутри джоба управляет реальными контейнерами на этой машине. Джоб сам ставит себе `node`,
.NET SDK через `apk`/`dotnet-install.sh` (см. workflow) — образ раннера намеренно голый, чтобы не `docker-cli` и .NET SDK через `apk`/`dotnet-install.sh` (см. workflow) — образ раннера намеренно
поддерживать отдельный кастомный образ. голый, чтобы не поддерживать отдельный кастомный образ. `node` ставится первым шагом, до
`actions/checkout` — этот шаг сам является JS-экшеном и без `node` в PATH падает с `Cannot find:
node in PATH` раньше, чем успевает выполниться что-либо ещё.
Проверить, что раннер подключился: Site Administration → Actions → Runners — должен появиться Проверить, что раннер подключился: Site Administration → Actions → Runners — должен появиться
`ruvdstest-prod` со статусом Idle/Online. `ruvdstest-prod` со статусом Idle/Online.
## Секреты
Джоб чекаутит репозиторий во временную директорию раннера, а не в `/opt/ruvdstests` — своего
`.env` там нет, поэтому `JWT_SECRET`/`POSTGRES_PASSWORD` для `docker compose build`/`up` берутся не
из файла, а из хранилища секретов самой Gitea Actions: Settings репозитория → Actions → Secrets →
**Add Secret**. Завести `JWT_SECRET` и `POSTGRES_PASSWORD` со **значениями, совпадающими с тем, что
уже лежит в `/opt/ruvdstests/.env`** на сервере (`cat /opt/ruvdstests/.env` на сервере, скопировать
оттуда) — деплой-шаг пересоздаёт тот же самый запущенный проект (`COMPOSE_PROJECT_NAME` выше), и
рассинхронизация паролей с уже инициализированным `pgdata`-volume сломает подключение к БД.
## Проверка ## Проверка
Сделайте любой коммит и запушьте в `master` — во вкладке **Actions** репозитория должен появиться Сделайте любой коммит и запушьте в `master` — во вкладке **Actions** репозитория должен появиться

View File

@@ -65,6 +65,25 @@ let private kindLabel (t: QuestionTypeView) =
| ShortAnswerT _ -> "Короткий ответ" | ShortAnswerT _ -> "Короткий ответ"
| NumericT _ -> "Числовой" | NumericT _ -> "Числовой"
/// Read-only rendering of a question's correct answer(s) for the teacher's
/// question-bank table distinct from `formFromSummary` in Types.fs, which
/// unpacks the same data to prefill the edit form instead of displaying it.
let private correctAnswerText (t: QuestionTypeView) =
match t with
| SingleChoiceT d ->
d.Options
|> List.tryFind (fun o -> o.Id = d.CorrectOptionId)
|> Option.map (fun o -> o.Text)
|> Option.defaultValue "—"
| MultipleChoiceT d ->
d.Options
|> List.filter (fun o -> List.contains o.Id d.CorrectOptionIds)
|> List.map (fun o -> o.Text)
|> String.concat ", "
| TrueFalseT b -> if b then "Верно" else "Неверно"
| ShortAnswerT d -> String.concat ", " d.AcceptedAnswers
| NumericT d -> sprintf "%s ± %s" (Format.points d.CorrectValue) (Format.points d.Tolerance)
let private questionsListView (model: Model) dispatch = let private questionsListView (model: Model) dispatch =
Html.div [ Html.div [
prop.className "questions-list" prop.className "questions-list"
@@ -78,18 +97,30 @@ let private questionsListView (model: Model) dispatch =
elif model.Questions.IsEmpty then elif model.Questions.IsEmpty then
Html.p "В этой теме пока нет вопросов" Html.p "В этой теме пока нет вопросов"
else else
Html.ul [ Html.table [
prop.className "questions-table"
prop.children [ prop.children [
Html.thead [
Html.tr [
Html.th [ prop.text "Текст вопроса" ]
Html.th [ prop.text "Тип" ]
Html.th [ prop.text "Правильный ответ" ]
Html.th [ prop.text "Баллы" ]
Html.th []
]
]
Html.tbody [
for q in model.Questions -> for q in model.Questions ->
Html.li [ Html.tr [
prop.key (string q.Id) prop.key (string q.Id)
prop.children [ prop.children [
Html.span [ prop.className "question-kind"; prop.text (kindLabel q.Type) ] Html.td [ prop.text q.Text ]
Html.text (sprintf " %s " q.Text) Html.td [ prop.className "question-kind"; prop.text (kindLabel q.Type) ]
Html.span [ Html.td [ prop.text (correctAnswerText q.Type) ]
prop.className "tag-mono" Html.td [ prop.className "tag-mono"; prop.text (Format.points q.Points) ]
prop.text (sprintf "%s б." (Format.points q.Points)) Html.td [
] prop.className "question-actions-cell"
prop.children [
Html.button [ Html.button [
prop.type'.button prop.type'.button
prop.onClick (fun _ -> dispatch (StartEditQuestion q.Id)) prop.onClick (fun _ -> dispatch (StartEditQuestion q.Id))
@@ -106,6 +137,9 @@ let private questionsListView (model: Model) dispatch =
] ]
] ]
] ]
]
]
]
let private optionsEditor (form: NewQuestionForm) dispatch (multi: bool) = let private optionsEditor (form: NewQuestionForm) dispatch (multi: bool) =
Html.div [ Html.div [

View File

@@ -681,17 +681,44 @@ a {
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
.questions-list li { .questions-table {
display: flex; width: 100%;
flex-wrap: wrap; border-collapse: collapse;
align-items: center; font-size: 0.9rem;
gap: 0.5rem;
border-top: 1px solid var(--line);
padding: 0.6rem 0;
} }
.questions-list li:first-child { .questions-table th,
border-top: none; .questions-table td {
text-align: left;
padding: 0.55rem 0.75rem;
border-bottom: 1px solid var(--line);
vertical-align: top;
}
.questions-table th {
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--ink-soft);
}
.questions-table tbody tr:hover {
background: var(--brass-soft);
}
/* `width: 1%` shrinks this column to just its buttons' content width —
since it's the only unconstrained column left, the question-text column
absorbs 100% of the remaining space instead, so the buttons stay pinned
flush right regardless of how long any question's text is. */
.question-actions-cell {
white-space: nowrap;
width: 1%;
text-align: right;
}
.question-actions-cell button {
margin-left: 0.5rem;
} }
.question-kind { .question-kind {
@@ -700,7 +727,6 @@ a {
color: var(--brass); color: var(--brass);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.03em; letter-spacing: 0.03em;
margin-right: 0.5rem;
} }
.new-question-form { .new-question-form {