feat(schema): Phase 1 schema templates + Nexus DevOps runbook

## AI Schema Assist Phase 1 (steps 1-2)

Approach A core (templates only, no LLM) — admin начинает создание нового
справочника с curated skeleton вместо пустого Monaco. Approach C (per-field
LLM suggest) — Phase 2 на этом фундаменте.

### Backend
- `templates/*.template.json` в ordinis-cuod-bundle resources — 6 шаблонов
  (blank/spacecraft/ground-station/antenna/frequency-band/operator)
- `SchemaTemplateRegistry` (rest-api) — classpath scan at startup, strip
  x-template-* metadata, expose canonical JSON Schema
- `SchemaTemplatesController` — GET /api/v1/dictionaries/templates (list)
  + /{id} (detail с schemaJson)
- Cross-bundle support: scanner matches classpath*:templates/*.template.json
  — другие bundles могут shippит свои templates через те же conventions

### Frontend
- types: SchemaTemplateSummary + SchemaTemplateDetail
- queries: schemaTemplatesQuery + lazy detail query
- TemplatePicker.tsx — grid of cards (icon + name + description + tags),
  click → fetch detail → fire onPick(detail)
- Integrated в DictionaryEditorDialog (create mode, properties.length===0
  visible — после первой property picker исчезает чтобы не перезатереть)

## Nexus DevOps handoff

`docs-internal/ops/nexus-alpine-mirror.md` — runbook про APKINDEX vs file
inventory drift на v3.23 mirror. Includes:
- Verification commands
- 3 fix options (invalidate cache / scheduled refresh / self-host)
- Prometheus alert proposal
- Current workaround (node:22-alpine3.20 pin) reference

Active issue paired с CI saga !227/!229/!230 — нужен DevOps action.
This commit is contained in:
Zimin A.N.
2026-05-16 13:20:43 +03:00
parent 5821baa0f1
commit c8dd5aad4e
13 changed files with 773 additions and 0 deletions
@@ -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) {}
}
@@ -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) {}
}