Compare commits

...
1 Commits
Author SHA1 Message Date
Denozordec b2dbb4ad98 feat(health-checks): enhance health check configuration and UI components
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
quality / web (push) Successful in 54s
quality / api (push) Successful in 52s
CD / quality (push) Successful in 1m55s
CD / publish (push) Successful in 1m54s
- Introduced a new HealthProviderToggle component to manage health check providers (local/cloudflare) in the UI.
- Updated health check configuration to include additional parameters such as retries and consecutive success/failure counts.
- Improved the service edit and group edit sheets to support the new health check provider options.
- Enhanced documentation to clarify the use of ACTIONS_PAT and GITEA_TOKEN for wiki updates.

This commit improves the health check management experience and expands the configuration options for better service monitoring.
2026-08-19 12:39:33 +07:00
7 changed files with 154 additions and 47 deletions
+2 -2
View File
@@ -35,13 +35,13 @@ Runner: `ubuntu-latest`, Docker для **docker-check** (PR) и **publish** (CD)
Повтор упавшего **publish** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-publish).
Job **update-wiki** идёт **параллельно** publish (не блокирует образы): при diff `docs/Home.md` копирует файл в wiki-репозиторий. Clone/push идут с `Authorization: Basic oauth2:<token>` — после clone git вырезает токен из `origin`, без header Gitea отвечает `Repository not found` (часто на внутреннем `GITEA_INSTANCE_URL` раннера). Секрет: **`GITEA_TOKEN`**, fallback **`ACTIONS_PAT`**.
Job **update-wiki** идёт **параллельно** publish (не блокирует образы): при diff `docs/Home.md` копирует файл в wiki-репозиторий. Clone/push идут на публичный **`https://git.shx.one`** (не внутренний `gitea.server_url` / `192.168.x.x:3000`): Gitea `ROOT_URL` совпадает с Host, иначе `git-receive-pack` wiki отвечает `Repository not found`. Токен в URL `https://oauth2:<PAT>@…/*.wiki.git` — Gitea на неаутентифицированный wiki push даёт **404, не 401**, поэтому `http.extraHeader` / ASKPASS не срабатывают. Секрет: **`ACTIONS_PAT`**, fallback **`GITEA_TOKEN`**.
### Секреты
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `gitea.token`. Push OCI — **только PAT** (у job token Gitea нет права packages).
**`GITEA_TOKEN`**: clone/push wiki.
**`GITEA_TOKEN`**: опциональный wiki-only PAT (fallback, если нет `ACTIONS_PAT`).
### Теги образов
+15 -10
View File
@@ -40,21 +40,26 @@ jobs:
- name: Update and push Wiki content
if: steps.check_changes.outputs.changed == 'true'
env:
WIKI_TOKEN: ${{ secrets.GITEA_TOKEN || secrets.ACTIONS_PAT }}
SERVER_URL: ${{ gitea.server_url }}
# ACTIONS_PAT уже пишет git (tags/releases). GITEA_TOKEN — опциональный
# wiki-only PAT; если он задан без write, Gitea отвечает 404, не 403.
WIKI_TOKEN: ${{ secrets.ACTIONS_PAT || secrets.GITEA_TOKEN }}
# Не gitea.server_url: на runner это внутренний http://192.168.x.x:3000,
# а ROOT_URL = git.shx.one — git-receive-pack wiki тогда даёт 404.
GITEA_PUBLIC_URL: https://git.shx.one
REPO: ${{ gitea.repository }}
run: |
set -euo pipefail
if [ -z "${WIKI_TOKEN:-}" ]; then
echo "GITEA_TOKEN / ACTIONS_PAT is empty — cannot push wiki"
echo "ACTIONS_PAT / GITEA_TOKEN is empty — cannot push wiki"
exit 1
fi
# runner GITEA_INSTANCE_URL часто внутренний (http://192.168.x.x:3000).
# git после clone вырезает userinfo из origin → push без токена даёт 404
# «Repository not found». Authorization header переживает insteadOf/sanitize.
WIKI_URL="${SERVER_URL}/${REPO}.wiki.git"
AUTH_HEADER="Authorization: Basic $(printf '%s' "oauth2:${WIKI_TOKEN}" | base64 | tr -d '\n')"
git -c http.extraHeader="${AUTH_HEADER}" clone "${WIKI_URL}" cfdm.wiki
PUBLIC_URL="${GITEA_PUBLIC_URL%/}"
TOKEN_ENC="$(python3 -c 'import urllib.parse,os; print(urllib.parse.quote(os.environ["WIKI_TOKEN"], safe=""))')"
WIKI_URL="${PUBLIC_URL}/${REPO}.wiki.git"
# Gitea на неаутентифицированный wiki push отвечает 404, не 401 —
# extraHeader/ASKPASS не помогают: токен должен быть в URL с первого запроса.
AUTH_INSTEAD="url.https://oauth2:${TOKEN_ENC}@${PUBLIC_URL#https://}/.insteadOf=${PUBLIC_URL}/"
GIT_TERMINAL_PROMPT=0 git -c "${AUTH_INSTEAD}" clone "${WIKI_URL}" cfdm.wiki
cp docs/Home.md cfdm.wiki/Home.md
cd cfdm.wiki
git config user.name "Gitea Actions"
@@ -65,7 +70,7 @@ jobs:
exit 0
fi
git commit -m "docs: Update Wiki from main repository"
git -c http.extraHeader="${AUTH_HEADER}" push origin HEAD
GIT_TERMINAL_PROMPT=0 git -c "${AUTH_INSTEAD}" push origin HEAD
publish:
needs: [quality]
@@ -17,11 +17,15 @@ import {
SelectValue,
} from '@cfdm/ui/components/select'
import { Switch } from '@cfdm/ui/components/switch'
import { Button } from '@cfdm/ui/components/button'
import { ButtonGroup } from '@cfdm/ui/components/button-group'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { cn } from '@cfdm/ui/lib/utils'
export type LbMode = 'round_robin' | 'failover' | 'weighted'
export type HealthCheckType = 'tcp' | 'http'
export type HealthProvider = 'local' | 'cloudflare'
export interface HealthCheckConfig {
enabled: boolean
@@ -32,7 +36,11 @@ export interface HealthCheckConfig {
interval_sec: number
timeout_ms: number
verify_tls: boolean
provider?: 'local' | 'cloudflare'
provider: HealthProvider
method?: string | null
retries?: number
consecutive_fails?: number
consecutive_successes?: number
}
export interface LbAndHealthConfig extends HealthCheckConfig {
@@ -50,6 +58,47 @@ const healthCheckTypes = [
{ value: 'http', label: 'HTTP' },
] as const
const cloudflareTypes = [
{ value: 'tcp', label: 'TCP' },
{ value: 'http', label: 'HTTP' },
] as const
export function HealthProviderToggle({
value,
onChange,
id,
}: {
value: HealthProvider
onChange: (next: HealthProvider) => void
id?: string
}) {
const provider = value || 'local'
return (
<ButtonGroup className="w-full" id={id}>
<Button
type="button"
size="sm"
className="flex-1"
variant={provider === 'local' ? 'secondary' : 'outline'}
aria-pressed={provider === 'local'}
onClick={() => onChange('local')}
>
Local
</Button>
<Button
type="button"
size="sm"
className="flex-1"
variant={provider === 'cloudflare' ? 'secondary' : 'outline'}
aria-pressed={provider === 'cloudflare'}
onClick={() => onChange('cloudflare')}
>
Cloudflare
</Button>
</ButtonGroup>
)
}
interface HealthCheckConfigFieldsProps {
value: LbAndHealthConfig
onChange: (next: LbAndHealthConfig) => void
@@ -120,6 +169,7 @@ export function HealthCheckConfigFields({
className={rowClass}
>
<Select
modal={false}
value={value.lb_mode}
onValueChange={(v) => patch({ lb_mode: (v ?? 'round_robin') as LbMode })}
>
@@ -144,21 +194,26 @@ export function HealthCheckConfigFields({
compact
className={rowClass}
>
<Select
<HealthProviderToggle
id={`${idPrefix}-provider`}
value={value.provider ?? 'local'}
onValueChange={(v) =>
patch({ provider: (v ?? 'local') as 'local' | 'cloudflare' })
onChange={(provider) =>
patch({
provider,
enabled: provider === 'cloudflare' ? true : value.enabled,
})
}
>
<SelectTrigger id={`${idPrefix}-provider`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">Local</SelectItem>
<SelectItem value="cloudflare">Cloudflare</SelectItem>
</SelectContent>
</Select>
/>
</SettingRow>
{value.provider === 'cloudflare' ? (
<Alert>
<AlertTitle>Cloudflare Health Checks</AlertTitle>
<AlertDescription>
Поля соответствуют официальному API зоны. Если план не позволяет Health
Checks, API вернёт ошибку останется Local. Workers не используются.
</AlertDescription>
</Alert>
) : null}
<SettingRow
title="Health-check"
@@ -188,6 +243,7 @@ export function HealthCheckConfigFields({
<div className="grid grid-cols-2 gap-3">
<FormFieldSimple label="Тип" htmlFor={`${idPrefix}-type`}>
<Select
modal={false}
value={value.type}
onValueChange={(v) => patch({ type: (v ?? 'tcp') as HealthCheckType })}
>
@@ -195,7 +251,10 @@ export function HealthCheckConfigFields({
<SelectValue placeholder="Тип" />
</SelectTrigger>
<SelectContent>
{healthCheckTypes.map((item) => (
{(value.provider === 'cloudflare'
? cloudflareTypes
: healthCheckTypes
).map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
@@ -289,6 +348,57 @@ export function HealthCheckConfigFields({
/>
</FormFieldSimple>
</div>
{value.provider === 'cloudflare' ? (
<div className="grid grid-cols-2 gap-3">
<FormFieldSimple
label="Retries"
htmlFor={`${idPrefix}-retries`}
hint="Cloudflare retries"
>
<CompactNumberField
id={`${idPrefix}-retries`}
value={value.retries ?? 2}
min={0}
max={10}
placeholder="2"
onValueChange={(retries) => patch({ retries: retries ?? 2 })}
/>
</FormFieldSimple>
<FormFieldSimple
label="Successes"
htmlFor={`${idPrefix}-successes`}
hint="consecutive_successes"
>
<CompactNumberField
id={`${idPrefix}-successes`}
value={value.consecutive_successes ?? 2}
min={1}
max={20}
placeholder="2"
onValueChange={(consecutive_successes) =>
patch({ consecutive_successes: consecutive_successes ?? 2 })
}
/>
</FormFieldSimple>
</div>
) : null}
{value.provider === 'cloudflare' && isHttp ? (
<FormFieldSimple label="HTTP method" htmlFor={`${idPrefix}-method`}>
<Select
modal={false}
value={value.method ?? 'GET'}
onValueChange={(v) => patch({ method: v ?? 'GET' })}
>
<SelectTrigger id={`${idPrefix}-method`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="HEAD">HEAD</SelectItem>
</SelectContent>
</Select>
</FormFieldSimple>
) : null}
</div>
) : null}
</FieldGroup>
@@ -57,6 +57,7 @@ interface BindingHealthConfig {
interval_sec: number
timeout_ms: number
verify_tls: boolean
provider: 'local' | 'cloudflare'
}
export interface ServiceBindingDraft {
@@ -79,6 +80,7 @@ const defaultHealth: BindingHealthConfig = {
interval_sec: 30,
timeout_ms: 3000,
verify_tls: false,
provider: 'local',
}
interface ServiceEditSheetProps {
@@ -112,6 +114,7 @@ function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
interval_sec: binding.health_check_interval_sec,
timeout_ms: binding.health_check_timeout_ms,
verify_tls: binding.health_check_verify_tls ?? false,
provider: 'local',
},
target_ip_weights: binding.target_ip_weights ?? {},
target_ip_priorities: binding.target_ip_priorities ?? {},
@@ -328,6 +331,7 @@ export function ServiceEditSheet({
interval_sec: next.interval_sec,
timeout_ms: next.timeout_ms,
verify_tls: next.verify_tls,
provider: next.provider ?? 'local',
},
}
: item,
@@ -650,6 +654,7 @@ export function ServiceEditSheet({
interval_sec: binding.health.interval_sec,
timeout_ms: binding.health.timeout_ms,
verify_tls: binding.health.verify_tls,
provider: binding.health.provider ?? 'local',
}}
onChange={(next) => handleBindingHealthChange(index, next)}
lbModeLabel="Режим балансировки"
@@ -54,6 +54,7 @@ const defaultLbHealth: LbAndHealthConfig = {
interval_sec: 30,
timeout_ms: 3000,
verify_tls: false,
provider: 'local',
}
export function ServiceGroupEditSheet({
@@ -93,6 +94,7 @@ export function ServiceGroupEditSheet({
interval_sec: group.health_check_interval_sec,
timeout_ms: group.health_check_timeout_ms,
verify_tls: group.health_check_verify_tls,
provider: 'local',
})
} else {
form.reset({ name: '', type: 'custom', domain: null })
@@ -10,14 +10,8 @@ import { FormFieldSimple } from '@/components/form-field'
import { LoadingButton } from '@/components/loading-button'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { HealthProviderToggle } from '@/components/health-check-config-fields'
import {
createOriginHealthCheck,
listOriginHealthChecks,
@@ -125,20 +119,11 @@ export function ServiceHealthPage() {
<Input id="hc-name" {...form.register('name')} />
</FormFieldSimple>
<FormFieldSimple label="Провайдер" htmlFor="hc-provider">
<Select
<HealthProviderToggle
id="hc-provider"
value={provider}
onValueChange={(value) =>
form.setValue('provider', (value as 'local' | 'cloudflare') ?? 'local')
}
>
<SelectTrigger id="hc-provider">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">Local</SelectItem>
<SelectItem value="cloudflare">Cloudflare</SelectItem>
</SelectContent>
</Select>
onChange={(next) => form.setValue('provider', next)}
/>
</FormFieldSimple>
{provider === 'cloudflare' ? (
<Alert>
+1 -1
View File
@@ -51,7 +51,7 @@ Workflows: [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml), [.gitea/work
Fallback для **git tag**: `gitea.token`, если PAT недоступен. Push образов в Container Registry — **только `ACTIONS_PAT`** (у job token Gitea нет права packages).
Wiki: секрет **`GITEA_TOKEN`** (fallback `ACTIONS_PAT`) для clone/push `*.wiki.git`. Push идёт с HTTP `Authorization`, потому что git после clone вырезает токен из remote URL.
Wiki: секрет **`ACTIONS_PAT`** (fallback `GITEA_TOKEN`) для clone/push `*.wiki.git` на `https://git.shx.one` (не внутренний `GITEA_INSTANCE_URL` раннера). Токен передаётся в URL (`oauth2:<PAT>`): Gitea на неаутентифицированный wiki push отвечает 404, а не 401.
## Источник правды для версии