Merge branch 'feat/templates-foundation' into 'main'
feat(schema): Phase 1 schema templates (Approach A) + Nexus DevOps runbook See merge request 2-6/2-6-4/terravault/ordinis!233
This commit is contained in:
@@ -0,0 +1,123 @@
|
|||||||
|
# Nexus Alpine Mirror — Operations Runbook
|
||||||
|
|
||||||
|
**Owner:** DevOps (cluster.265 infra)
|
||||||
|
**Status:** Active issue — APKINDEX рассинхронизирован с file storage на v3.23
|
||||||
|
**Related:** ordinis ci/apk fixes chain (!227, !229, !230)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Nexus mirror на `repo.nstart.cloud/repository/alpine/v3.23/main/x86_64/`:
|
||||||
|
|
||||||
|
| Что | Состояние |
|
||||||
|
|---|---|
|
||||||
|
| APKINDEX.tar.gz | Claims `ca-certificates-20251003-r0`, `libexpat-2.7.3-r0` |
|
||||||
|
| Filesystem | Только `ca-certificates-20260413-r0.apk`, `libexpat-2.7.5-r0.apk` |
|
||||||
|
| Result | `apk add` → HTTP 404 на 2 packages → fail |
|
||||||
|
|
||||||
|
Проверить актуальность:
|
||||||
|
```bash
|
||||||
|
curl -s https://repo.nstart.cloud/repository/alpine/v3.23/main/x86_64/APKINDEX.tar.gz \
|
||||||
|
| tar xz -C /tmp/ \
|
||||||
|
&& grep -A1 "^P:ca-certificates$" /tmp/APKINDEX | head -2
|
||||||
|
|
||||||
|
# Сравнить с file inventory:
|
||||||
|
curl -s "https://repo.nstart.cloud/repository/alpine/v3.23/main/x86_64/" \
|
||||||
|
| grep "ca-certificates-" | head -3
|
||||||
|
```
|
||||||
|
|
||||||
|
v3.20 mirror — **consistent** (APKINDEX и files matching). v3.21+ — нужно проверить per-version.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `node:22-alpine` (latest) uses Alpine v3.23 → broken `apk add` в CI
|
||||||
|
- Releases pipeline ломается на 2 stages (release job → semantic-release)
|
||||||
|
- `propose-prod-image-bump` (alpine:3.20 image) — **работает** благодаря consistent v3.20 mirror
|
||||||
|
|
||||||
|
## Workaround (active в проде)
|
||||||
|
|
||||||
|
ordinis `.gitlab-ci.yml` pins `release` job:
|
||||||
|
```yaml
|
||||||
|
release:
|
||||||
|
image: repo.nstart.cloud/library/node:22-alpine3.20 # explicit v3.20 pin
|
||||||
|
```
|
||||||
|
|
||||||
|
Все Alpine jobs делают sed swap к internal mirror + `apk update`:
|
||||||
|
```yaml
|
||||||
|
- sed -i 's|https://dl-cdn.alpinelinux.org/alpine|https://repo.nstart.cloud/repository/alpine|g' /etc/apk/repositories
|
||||||
|
- apk update
|
||||||
|
- apk add --no-cache <packages>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix (нужен DevOps action)
|
||||||
|
|
||||||
|
### Option 1 (recommended): Force Nexus APKINDEX rebuild
|
||||||
|
|
||||||
|
Nexus admin UI / API → `repository/alpine/v3.23/main/x86_64/APKINDEX.tar.gz`:
|
||||||
|
```bash
|
||||||
|
# Trigger rebuild через Nexus API (требует admin token)
|
||||||
|
curl -X POST -u admin:<token> \
|
||||||
|
"https://repo.nstart.cloud/service/rest/v1/repositories/alpine/proxy/<repo>/invalidate-cache"
|
||||||
|
```
|
||||||
|
|
||||||
|
Или через UI: `Repositories → alpine-proxy → Invalidate Cache`.
|
||||||
|
|
||||||
|
После invalidate — следующий apk request триггерит fresh fetch upstream APKINDEX.
|
||||||
|
|
||||||
|
### Option 2: Schedule periodic refresh
|
||||||
|
|
||||||
|
Nexus settings → `Repositories → alpine → Connector → Maximum component age`. Set to `1 hour` (default может быть 24h+).
|
||||||
|
|
||||||
|
Trade-off: чаще updates → больше upstream traffic. Для CI критично — рекомендую `1h` для APKINDEX, `7d` для package files (immutable).
|
||||||
|
|
||||||
|
### Option 3 (last resort): Self-host Alpine mirror
|
||||||
|
|
||||||
|
Если upstream Alpine periodically переписывает APKINDEX faster than Nexus refresh интервал — поднять dedicated mirror через `rsync` от tier-1 Alpine mirror каждые 30 мин.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification после fix
|
||||||
|
|
||||||
|
1. Test mirror consistency:
|
||||||
|
```bash
|
||||||
|
APK_CC=$(curl -s https://repo.nstart.cloud/repository/alpine/v3.23/main/x86_64/APKINDEX.tar.gz \
|
||||||
|
| tar xz -O APKINDEX | grep -A1 '^P:ca-certificates$' | tail -1 | sed 's/^V://')
|
||||||
|
FILE_CC=$(curl -s https://repo.nstart.cloud/repository/alpine/v3.23/main/x86_64/ \
|
||||||
|
| grep -o 'ca-certificates-[0-9]*-r[0-9]*' | head -1 | sed 's/ca-certificates-//')
|
||||||
|
[ "$APK_CC" = "$FILE_CC" ] && echo "OK: APKINDEX и files matching ($APK_CC)" \
|
||||||
|
|| echo "DRIFT: APKINDEX=$APK_CC files=$FILE_CC"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Revert `node:22-alpine3.20` pin в `.gitlab-ci.yml` обратно на `node:22-alpine`:
|
||||||
|
```diff
|
||||||
|
- image: repo.nstart.cloud/library/node:22-alpine3.20
|
||||||
|
+ image: repo.nstart.cloud/library/node:22-alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Run release pipeline — должна complete без 404 в apk add.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Если воспроизводится снова
|
||||||
|
|
||||||
|
Это второе incident за месяц (см. CI saga 2026-05-15 — `!227 → !229 → !230`). Нужен monitoring:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Prometheus alert proposal
|
||||||
|
- alert: NexusAlpineMirrorDrift
|
||||||
|
expr: |
|
||||||
|
apk_index_version{repo="alpine-v3.23"} != apk_package_version{repo="alpine-v3.23", package="ca-certificates"}
|
||||||
|
for: 1h
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Nexus Alpine mirror v3.23 APKINDEX out of sync"
|
||||||
|
runbook: "docs-internal/ops/nexus-alpine-mirror.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
(Метрики писать через cron job + node_exporter textfile collector — Nexus сам не отдаёт мониторинг APKINDEX consistency.)
|
||||||
@@ -776,6 +776,24 @@ export const NOTIFICATION_EVENT_TYPES = [
|
|||||||
] as const
|
] as const
|
||||||
export type NotificationEventType = (typeof NOTIFICATION_EVENT_TYPES)[number]
|
export type NotificationEventType = (typeof NOTIFICATION_EVENT_TYPES)[number]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema template summary (AI Schema Assist Phase 1, Approach A core).
|
||||||
|
* Curated skeleton schemas для quick start при создании нового справочника.
|
||||||
|
* Поле {@code icon} опционально — phosphor-icons name string.
|
||||||
|
*/
|
||||||
|
export type SchemaTemplateSummary = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
icon: string | null
|
||||||
|
tags: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full schema template c JSON Schema body (lazy fetch when user picks). */
|
||||||
|
export type SchemaTemplateDetail = SchemaTemplateSummary & {
|
||||||
|
schemaJson: unknown
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Empty-state hint payload (read-api scheduled-summary). Подсчёт записей с
|
* Empty-state hint payload (read-api scheduled-summary). Подсчёт записей с
|
||||||
* {@code validFrom > now AND validTo > now} в текущем scope view.
|
* {@code validFrom > now AND validTo > now} в текущем scope view.
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import {
|
|||||||
type ReviewQueuePage,
|
type ReviewQueuePage,
|
||||||
type ScheduledRecordsSummary,
|
type ScheduledRecordsSummary,
|
||||||
type SchemaDependent,
|
type SchemaDependent,
|
||||||
|
type SchemaTemplateDetail,
|
||||||
|
type SchemaTemplateSummary,
|
||||||
type SearchResponse,
|
type SearchResponse,
|
||||||
type WebhookDeliveryPage,
|
type WebhookDeliveryPage,
|
||||||
type WebhookDeliveryStats,
|
type WebhookDeliveryStats,
|
||||||
@@ -816,6 +818,40 @@ export const useScheduledSummary = (dictionaryName: string, scopeCsv: string) =>
|
|||||||
...scheduledSummaryQuery(dictionaryName, scopeCsv),
|
...scheduledSummaryQuery(dictionaryName, scopeCsv),
|
||||||
enabled: Boolean(dictionaryName),
|
enabled: Boolean(dictionaryName),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Schema templates (AI Schema Assist Phase 1, Approach A core)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catalog of curated schema templates — lightweight summary без schemaJson.
|
||||||
|
* Stale time 5min — templates change только при deploy.
|
||||||
|
*/
|
||||||
|
export const schemaTemplatesQuery = queryOptions({
|
||||||
|
queryKey: ['schema-templates'] as const,
|
||||||
|
queryFn: async (): Promise<SchemaTemplateSummary[]> => {
|
||||||
|
const { data } = await apiClient.get<SchemaTemplateSummary[]>(
|
||||||
|
'/dictionaries/templates',
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useSchemaTemplates = () => useQuery(schemaTemplatesQuery)
|
||||||
|
|
||||||
|
/** Detail fetch — lazy when user picks template. */
|
||||||
|
export const schemaTemplateDetailQuery = (id: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['schema-templates', id] as const,
|
||||||
|
queryFn: async (): Promise<SchemaTemplateDetail> => {
|
||||||
|
const { data } = await apiClient.get<SchemaTemplateDetail>(
|
||||||
|
`/dictionaries/templates/${id}`,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
})
|
||||||
export const useRecordRaw = (
|
export const useRecordRaw = (
|
||||||
dictionaryName: string,
|
dictionaryName: string,
|
||||||
businessKey: string | undefined,
|
businessKey: string | undefined,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { useCreateDictionary, useUpdateDictionary } from '@/api/mutations'
|
|||||||
import { dictionaryDetailQuery, useDictionaries } from '@/api/queries'
|
import { dictionaryDetailQuery, useDictionaries } from '@/api/queries'
|
||||||
import type { CreateDictionaryRequest, DataScope, DictionaryDetail } from '@/api/client'
|
import type { CreateDictionaryRequest, DataScope, DictionaryDetail } from '@/api/client'
|
||||||
import { SchemaBuilder } from './SchemaBuilder'
|
import { SchemaBuilder } from './SchemaBuilder'
|
||||||
|
import { TemplatePicker } from './TemplatePicker'
|
||||||
import { EventsPreviewTab } from './EventsPreviewTab'
|
import { EventsPreviewTab } from './EventsPreviewTab'
|
||||||
import { CreateSchemaDraftModal } from '@/components/workflow/CreateSchemaDraftModal'
|
import { CreateSchemaDraftModal } from '@/components/workflow/CreateSchemaDraftModal'
|
||||||
import {
|
import {
|
||||||
@@ -420,6 +421,23 @@ export const DictionaryEditorDialog = ({ open, mode, onClose, onSuccess }: Props
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={activeTab === 'schema' ? 'block' : 'hidden'}>
|
<div className={activeTab === 'schema' ? 'block' : 'hidden'}>
|
||||||
|
{/* AI Schema Assist Phase 1: template picker visible только в create-mode
|
||||||
|
* И только пока пользователь не начал заполнять properties (чтобы не
|
||||||
|
* перезатереть его работу одним кликом). После первой property —
|
||||||
|
* picker исчезает (можно reset через template via existing dict templ
|
||||||
|
* UI выше или вручную clear). */}
|
||||||
|
{!isEdit && properties.length === 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<TemplatePicker
|
||||||
|
disabled={loadingTemplate || createMut.isPending}
|
||||||
|
onPick={(template) => {
|
||||||
|
const tplParsed = parseSchemaJson(template.schemaJson)
|
||||||
|
setProperties(tplParsed.properties)
|
||||||
|
setIdSource(tplParsed.idSource ?? '')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<SchemaBuilder properties={properties} onChange={setProperties} />
|
<SchemaBuilder properties={properties} onChange={setProperties} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
BroadcastIcon,
|
||||||
|
BuildingsIcon,
|
||||||
|
FileDashedIcon,
|
||||||
|
RocketIcon,
|
||||||
|
WaveformIcon,
|
||||||
|
WifiHighIcon,
|
||||||
|
type Icon,
|
||||||
|
} from '@phosphor-icons/react'
|
||||||
|
import { LoadingBlock } from '@/ui'
|
||||||
|
import {
|
||||||
|
schemaTemplateDetailQuery,
|
||||||
|
useSchemaTemplates,
|
||||||
|
} from '@/api/queries'
|
||||||
|
import type { SchemaTemplateDetail, SchemaTemplateSummary } from '@/api/client'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI Schema Assist Phase 1 step 2 — curated template chooser.
|
||||||
|
*
|
||||||
|
* <p>Render'ит grid of template cards. Click card → fetch full schema lazy →
|
||||||
|
* fire {@code onPick(detail)}. Caller (DictionaryEditorDialog) применяет
|
||||||
|
* schemaJson через parseSchemaJson + setProperties.
|
||||||
|
*
|
||||||
|
* <p>Approach A (templates only) — works без LLM. Approach C upgrade
|
||||||
|
* (per-field AI suggest) — Phase 2, отдельный UI elsewhere.
|
||||||
|
*
|
||||||
|
* <p>Icon name из template metadata → resolved через {@link ICON_MAP}.
|
||||||
|
* Unknown icon → FileBlankIcon fallback.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ICON_MAP: Record<string, Icon> = {
|
||||||
|
// Template metadata icons mapped к phosphor-icons available в bundle.
|
||||||
|
// SatelliteIcon отсутствует в @phosphor-icons/react@2.1.10 → RocketIcon
|
||||||
|
// используется как proxy для space-domain templates.
|
||||||
|
SatelliteIcon: RocketIcon,
|
||||||
|
RocketIcon,
|
||||||
|
BroadcastIcon,
|
||||||
|
WifiHighIcon,
|
||||||
|
WaveformIcon,
|
||||||
|
BuildingsIcon,
|
||||||
|
FileBlankIcon: FileDashedIcon,
|
||||||
|
FileDashedIcon,
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveIcon = (name: string | null): Icon => {
|
||||||
|
if (!name) return FileDashedIcon
|
||||||
|
return ICON_MAP[name] ?? FileDashedIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** Called когда юзер выбрал template — caller получает full detail с schemaJson. */
|
||||||
|
onPick: (template: SchemaTemplateDetail) => void
|
||||||
|
/** Disable picker когда parent занят (mutation в полёте). */
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemplatePicker({ onPick, disabled }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { data: templates, isLoading, isError } = useSchemaTemplates()
|
||||||
|
|
||||||
|
const handlePick = async (template: SchemaTemplateSummary) => {
|
||||||
|
if (disabled) return
|
||||||
|
try {
|
||||||
|
const detail = await queryClient.fetchQuery(
|
||||||
|
schemaTemplateDetailQuery(template.id),
|
||||||
|
)
|
||||||
|
onPick(detail)
|
||||||
|
} catch {
|
||||||
|
// Quiet fail — caller ничего не получает, юзер видит no change.
|
||||||
|
// Real error случится только если template исчез между list и detail
|
||||||
|
// fetch (deploy в гонке). Caller справится через retry.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) return <LoadingBlock size="sm" label={t('loading')} />
|
||||||
|
if (isError || !templates || templates.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="text-cap text-mute uppercase tracking-wider">
|
||||||
|
{t('schemaTemplates.label', { defaultValue: 'Начать с шаблона' })}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||||
|
{templates.map((template) => {
|
||||||
|
const IconComp = resolveIcon(template.icon)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={template.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handlePick(template)}
|
||||||
|
disabled={disabled}
|
||||||
|
title={template.description}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-start gap-1 p-3 rounded-md border border-line bg-surface',
|
||||||
|
'hover:border-line-2 hover:bg-surface-2 transition-colors text-left',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
|
disabled && 'opacity-50 cursor-not-allowed hover:bg-surface hover:border-line',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 w-full">
|
||||||
|
<IconComp size={18} weight="regular" className="shrink-0 text-accent" />
|
||||||
|
<span className="text-body font-semibold text-ink truncate">
|
||||||
|
{template.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-cell text-ink-2 line-clamp-2">
|
||||||
|
{template.description}
|
||||||
|
</span>
|
||||||
|
{template.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||||
|
{template.tags.slice(0, 3).map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="text-[10px] text-mute uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="text-cell text-mute">
|
||||||
|
{t('schemaTemplates.hint', {
|
||||||
|
defaultValue: 'Шаблон загрузит начальные поля. Дополни их вручную.',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"$comment": "Template: antenna — minimal skeleton с FK на ground_station",
|
||||||
|
"x-template-id": "antenna",
|
||||||
|
"x-template-name": "Антенна",
|
||||||
|
"x-template-description": "Скелет: код, имя, диаметр, FK на наземную станцию. Диапазоны и G/T добавь сам.",
|
||||||
|
"x-template-icon": "WifiHighIcon",
|
||||||
|
"x-template-tags": ["space", "hardware", "cuod"],
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"type": "object",
|
||||||
|
"x-id-source": "code",
|
||||||
|
"required": ["code", "name", "ground_station"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Уникальный код антенны",
|
||||||
|
"pattern": "^[A-Z][A-Z0-9_]{1,31}$",
|
||||||
|
"x-unique": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "object",
|
||||||
|
"x-localized": true,
|
||||||
|
"description": "Наименование антенны",
|
||||||
|
"patternProperties": {
|
||||||
|
"^[a-z]{2}-[A-Z]{2}$": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"required": ["ru-RU"]
|
||||||
|
},
|
||||||
|
"diameter_m": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Диаметр зеркала, м",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"ground_station": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "FK на ground_station.code",
|
||||||
|
"pattern": "^[A-Z][A-Z0-9_]{1,31}$",
|
||||||
|
"x-references": "ground_station.code"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$comment": "Template: blank starter",
|
||||||
|
"x-template-id": "blank",
|
||||||
|
"x-template-name": "Пустой каркас",
|
||||||
|
"x-template-description": "Только бизнес-ключ (code). Все остальные поля добавь сам.",
|
||||||
|
"x-template-icon": "FileBlankIcon",
|
||||||
|
"x-template-tags": ["empty", "starter"],
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"type": "object",
|
||||||
|
"x-id-source": "code",
|
||||||
|
"required": ["code"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Бизнес-ключ",
|
||||||
|
"pattern": "^[A-Z][A-Z0-9_]{1,31}$",
|
||||||
|
"x-unique": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"$comment": "Template: frequency-band — min/max МГц + band code",
|
||||||
|
"x-template-id": "frequency-band",
|
||||||
|
"x-template-name": "Диапазон частот",
|
||||||
|
"x-template-description": "Скелет: код, имя, нижняя/верхняя частота (МГц).",
|
||||||
|
"x-template-icon": "WaveformIcon",
|
||||||
|
"x-template-tags": ["radio", "cuod"],
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"type": "object",
|
||||||
|
"x-id-source": "code",
|
||||||
|
"required": ["code", "name", "min_mhz", "max_mhz"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Код диапазона (e.g. L_BAND)",
|
||||||
|
"pattern": "^[A-Z][A-Z0-9_]{1,31}$",
|
||||||
|
"x-unique": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "object",
|
||||||
|
"x-localized": true,
|
||||||
|
"description": "Наименование",
|
||||||
|
"patternProperties": {
|
||||||
|
"^[a-z]{2}-[A-Z]{2}$": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"required": ["ru-RU"]
|
||||||
|
},
|
||||||
|
"min_mhz": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Нижняя частота, МГц",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"max_mhz": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Верхняя частота, МГц",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"$comment": "Template: ground-station — minimal skeleton с lat/lon",
|
||||||
|
"x-template-id": "ground-station",
|
||||||
|
"x-template-name": "Наземная станция",
|
||||||
|
"x-template-description": "Скелет: код, имя, координаты, страна. Антенны и оборудование добавь сам.",
|
||||||
|
"x-template-icon": "BroadcastIcon",
|
||||||
|
"x-template-tags": ["space", "ground", "cuod"],
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"type": "object",
|
||||||
|
"x-id-source": "code",
|
||||||
|
"required": ["code", "name", "lat", "lon"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Уникальный код станции",
|
||||||
|
"pattern": "^[A-Z][A-Z0-9_]{1,31}$",
|
||||||
|
"x-unique": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "object",
|
||||||
|
"x-localized": true,
|
||||||
|
"description": "Наименование станции",
|
||||||
|
"patternProperties": {
|
||||||
|
"^[a-z]{2}-[A-Z]{2}$": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"required": ["ru-RU"]
|
||||||
|
},
|
||||||
|
"lat": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Широта (°)",
|
||||||
|
"minimum": -90,
|
||||||
|
"maximum": 90
|
||||||
|
},
|
||||||
|
"lon": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Долгота (°)",
|
||||||
|
"minimum": -180,
|
||||||
|
"maximum": 180
|
||||||
|
},
|
||||||
|
"country": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "ISO 3166-1 alpha-2 страны размещения",
|
||||||
|
"pattern": "^[A-Z]{2}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"$comment": "Template: operator (оператор КА / земсегмента)",
|
||||||
|
"x-template-id": "operator",
|
||||||
|
"x-template-name": "Оператор",
|
||||||
|
"x-template-description": "Скелет: код, имя, страна, контакт. Лицензии и контракты добавь сам.",
|
||||||
|
"x-template-icon": "BuildingsIcon",
|
||||||
|
"x-template-tags": ["org", "cuod"],
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"type": "object",
|
||||||
|
"x-id-source": "code",
|
||||||
|
"required": ["code", "name", "country"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Уникальный код оператора",
|
||||||
|
"pattern": "^[A-Z][A-Z0-9_]{1,31}$",
|
||||||
|
"x-unique": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "object",
|
||||||
|
"x-localized": true,
|
||||||
|
"description": "Наименование оператора",
|
||||||
|
"patternProperties": {
|
||||||
|
"^[a-z]{2}-[A-Z]{2}$": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"required": ["ru-RU"]
|
||||||
|
},
|
||||||
|
"country": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "ISO 3166-1 alpha-2 страны",
|
||||||
|
"pattern": "^[A-Z]{2}$"
|
||||||
|
},
|
||||||
|
"contact_email": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "email",
|
||||||
|
"description": "Контактный email"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"$comment": "Template: spacecraft (ЦУОД) — minimal skeleton",
|
||||||
|
"x-template-id": "spacecraft",
|
||||||
|
"x-template-name": "Космический аппарат",
|
||||||
|
"x-template-description": "Скелет для справочника КА: код, имя, тип, страна. Орбиту/мaccу/спектр добавь сам.",
|
||||||
|
"x-template-icon": "SatelliteIcon",
|
||||||
|
"x-template-tags": ["space", "cuod"],
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"type": "object",
|
||||||
|
"x-id-source": "code",
|
||||||
|
"required": ["code", "name"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Уникальный код КА",
|
||||||
|
"pattern": "^[A-Z][A-Z0-9_]{1,31}$",
|
||||||
|
"x-unique": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "object",
|
||||||
|
"x-localized": true,
|
||||||
|
"description": "Наименование КА",
|
||||||
|
"patternProperties": {
|
||||||
|
"^[a-z]{2}-[A-Z]{2}$": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"required": ["ru-RU"]
|
||||||
|
},
|
||||||
|
"satellite_type_code": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Тип КА (FK на satellite_type)",
|
||||||
|
"pattern": "^[A-Z][A-Z0-9_]{1,31}$",
|
||||||
|
"x-references": "satellite_type.code"
|
||||||
|
},
|
||||||
|
"country": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "ISO 3166-1 alpha-2 страны-оператора",
|
||||||
|
"pattern": "^[A-Z]{2}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.service.template;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||||
|
import org.springframework.core.io.support.ResourcePatternResolver;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI Schema Assist — Phase 1 step 1: curated schema templates registry.
|
||||||
|
*
|
||||||
|
* <p>Scans classpath {@code templates/*.template.json} at startup. Каждый файл —
|
||||||
|
* полная JSON Schema + дополнительные {@code x-template-*} metadata header
|
||||||
|
* (id/name/description/icon/tags) для каталога UI.
|
||||||
|
*
|
||||||
|
* <p>Approach A (templates only) core. Pure offline — no LLM dep. Templates
|
||||||
|
* ship as part of {@code ordinis-cuod-bundle}; другие bundles могут добавить
|
||||||
|
* свои templates в собственные {@code resources/templates/} — scanner picks
|
||||||
|
* them all up (classpath wildcard).
|
||||||
|
*
|
||||||
|
* <p>Approach C upgrade path: AI suggest-field будет принимать template'у как
|
||||||
|
* starting context и предлагать additional fields — но это Phase 1 step 4-6,
|
||||||
|
* не сейчас.
|
||||||
|
*
|
||||||
|
* <p>Templates immutable per deploy — reload требует restart. Acceptable
|
||||||
|
* trade-off: templates меняются редко, hot-reload добавляет complexity без
|
||||||
|
* value.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class SchemaTemplateRegistry {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(SchemaTemplateRegistry.class);
|
||||||
|
private static final String TEMPLATES_PATTERN = "classpath*:templates/*.template.json";
|
||||||
|
|
||||||
|
private final ObjectMapper mapper;
|
||||||
|
private final ResourcePatternResolver resourceResolver;
|
||||||
|
private final Map<String, SchemaTemplate> templatesById = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
public SchemaTemplateRegistry(ObjectMapper mapper) {
|
||||||
|
this.mapper = mapper;
|
||||||
|
this.resourceResolver = new PathMatchingResourcePatternResolver();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
void loadTemplates() {
|
||||||
|
try {
|
||||||
|
Resource[] resources = resourceResolver.getResources(TEMPLATES_PATTERN);
|
||||||
|
for (Resource resource : resources) {
|
||||||
|
try (var is = resource.getInputStream()) {
|
||||||
|
JsonNode root = mapper.readTree(is);
|
||||||
|
SchemaTemplate tpl = parseTemplate(root, resource.getFilename());
|
||||||
|
if (tpl != null) {
|
||||||
|
templatesById.put(tpl.id(), tpl);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Skipping malformed template {}: {}", resource.getFilename(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info("Loaded {} schema templates: {}", templatesById.size(), templatesById.keySet());
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to scan templates classpath", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SchemaTemplate parseTemplate(JsonNode root, String fileName) {
|
||||||
|
String id = textOrNull(root, "x-template-id");
|
||||||
|
if (id == null || id.isBlank()) {
|
||||||
|
log.warn("Template {} missing x-template-id — skipping", fileName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String name = Optional.ofNullable(textOrNull(root, "x-template-name")).orElse(id);
|
||||||
|
String description = Optional.ofNullable(textOrNull(root, "x-template-description")).orElse("");
|
||||||
|
String icon = textOrNull(root, "x-template-icon");
|
||||||
|
|
||||||
|
List<String> tags = new ArrayList<>();
|
||||||
|
JsonNode tagsNode = root.get("x-template-tags");
|
||||||
|
if (tagsNode != null && tagsNode.isArray()) {
|
||||||
|
tagsNode.forEach(n -> { if (n.isTextual()) tags.add(n.asText()); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stripped schema = template без x-template-* metadata (clean JSON Schema
|
||||||
|
// готова к загрузке в Monaco / dictionary creation). Keep $schema, type,
|
||||||
|
// required, properties, x-id-source — это canonical schema content.
|
||||||
|
var stripped = root.deepCopy();
|
||||||
|
if (stripped.isObject()) {
|
||||||
|
var obj = (com.fasterxml.jackson.databind.node.ObjectNode) stripped;
|
||||||
|
obj.remove("x-template-id");
|
||||||
|
obj.remove("x-template-name");
|
||||||
|
obj.remove("x-template-description");
|
||||||
|
obj.remove("x-template-icon");
|
||||||
|
obj.remove("x-template-tags");
|
||||||
|
obj.remove("$comment");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SchemaTemplate(
|
||||||
|
id, name, description, icon,
|
||||||
|
Collections.unmodifiableList(tags),
|
||||||
|
stripped);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String textOrNull(JsonNode root, String field) {
|
||||||
|
JsonNode n = root.get(field);
|
||||||
|
return (n != null && n.isTextual()) ? n.asText() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SchemaTemplate> all() {
|
||||||
|
return new ArrayList<>(templatesById.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<SchemaTemplate> byId(String id) {
|
||||||
|
return Optional.ofNullable(templatesById.get(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema template metadata + cleaned schema JSON.
|
||||||
|
*
|
||||||
|
* @param id unique template identifier (matches filename prefix usually)
|
||||||
|
* @param name human-readable name (RU/EN agnostic — admin sees it)
|
||||||
|
* @param description short blurb что внутри + что admin должен дополнить
|
||||||
|
* @param icon optional phosphor-icons icon name (e.g. "SatelliteIcon")
|
||||||
|
* @param tags tags для filtering в UI (e.g. ["space", "cuod"])
|
||||||
|
* @param schemaJson canonical JSON Schema (без x-template-* metadata)
|
||||||
|
*/
|
||||||
|
public record SchemaTemplate(
|
||||||
|
String id,
|
||||||
|
String name,
|
||||||
|
String description,
|
||||||
|
String icon,
|
||||||
|
List<String> tags,
|
||||||
|
JsonNode schemaJson) {}
|
||||||
|
}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.restapi.web;
|
||||||
|
|
||||||
|
import cloud.nstart.terravault.ordinis.restapi.service.template.SchemaTemplateRegistry;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI Schema Assist Phase 1 — templates listing endpoint.
|
||||||
|
*
|
||||||
|
* <p>{@code GET /api/v1/dictionaries/templates} — каталог доступных шаблонов
|
||||||
|
* без полной schema (lightweight для grid rendering).
|
||||||
|
*
|
||||||
|
* <p>{@code GET /api/v1/dictionaries/templates/{id}} — полная JSON Schema
|
||||||
|
* для выбранного шаблона (lazy load когда admin кликнул).
|
||||||
|
*
|
||||||
|
* <p>Public endpoint (auth optional) — templates сами по себе не sensitive,
|
||||||
|
* это shared catalog для admins. RBAC будет gate'нуть actual dict creation
|
||||||
|
* (downstream), а здесь только preview.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/dictionaries/templates")
|
||||||
|
public class SchemaTemplatesController {
|
||||||
|
|
||||||
|
private final SchemaTemplateRegistry registry;
|
||||||
|
|
||||||
|
public SchemaTemplatesController(SchemaTemplateRegistry registry) {
|
||||||
|
this.registry = registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Catalog — без schemaJson (экономит ~3KB на шаблон в list view). */
|
||||||
|
@GetMapping
|
||||||
|
public List<TemplateSummary> list() {
|
||||||
|
return registry.all().stream()
|
||||||
|
.map(t -> new TemplateSummary(t.id(), t.name(), t.description(), t.icon(), t.tags()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detail — full JSON Schema готовая к загрузке в Monaco. */
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public TemplateDetail get(@PathVariable String id) {
|
||||||
|
return registry.byId(id)
|
||||||
|
.map(t -> new TemplateDetail(
|
||||||
|
t.id(), t.name(), t.description(), t.icon(), t.tags(), t.schemaJson()))
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(
|
||||||
|
HttpStatus.NOT_FOUND, "Template not found: " + id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public record TemplateSummary(
|
||||||
|
String id,
|
||||||
|
String name,
|
||||||
|
String description,
|
||||||
|
String icon,
|
||||||
|
List<String> tags) {}
|
||||||
|
|
||||||
|
public record TemplateDetail(
|
||||||
|
String id,
|
||||||
|
String name,
|
||||||
|
String description,
|
||||||
|
String icon,
|
||||||
|
List<String> tags,
|
||||||
|
JsonNode schemaJson) {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user