UI : vocabulaire unifié, comptes + mots de passe, choix par élément, clé AES auto
This commit is contained in:
1 parent
ee8343c69f
commit
2ca2f87307
19 files changed
+377
-143
No files matched your search
+2
-3
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
# ---------- Commun ----------
|
# ---------- Commun ----------
|
||||||
TZ=Europe/Paris
|
TZ=Europe/Paris
|
||||||
# Port publié par docker-compose.demo.yml
|
# Port publié sur l'hôte (docker-compose.yml et docker-compose.demo.yml)
|
||||||
# CONFSYNC_PORT=8080
|
# CONFSYNC_PORT=8080
|
||||||
|
|
||||||
# ---------- Interface web ----------
|
# ---------- Interface web ----------
|
||||||
@@ -18,8 +18,7 @@ TZ=Europe/Paris
|
|||||||
# Clé AES-256 des mots de passe root en base (64 caractères hex ou base64 de 32 octets) :
|
# Clé AES-256 des mots de passe root en base (64 caractères hex ou base64 de 32 octets) :
|
||||||
# openssl rand -hex 32
|
# openssl rand -hex 32
|
||||||
# Ordre de lecture : CONFSYNC_SECRET_KEY, puis CONFSYNC_SECRET_KEY_FILE, sinon DATA_DIR/secret.key
|
# Ordre de lecture : CONFSYNC_SECRET_KEY, puis CONFSYNC_SECRET_KEY_FILE, sinon DATA_DIR/secret.key
|
||||||
# (créée au 1er démarrage). Le compose fournit déjà CONFSYNC_SECRET_KEY_FILE ; une valeur
|
# (créée automatiquement au 1er démarrage). Une valeur CONFSYNC_SECRET_KEY ici est prioritaire. Sans cette clé, les mots de passe enregistrés sont perdus.
|
||||||
# CONFSYNC_SECRET_KEY ici serait prioritaire. Sans cette clé, les mots de passe enregistrés sont perdus.
|
|
||||||
# CONFSYNC_SECRET_KEY=
|
# CONFSYNC_SECRET_KEY=
|
||||||
# CONFSYNC_SECRET_KEY_FILE=/run/secrets/confsync_key
|
# CONFSYNC_SECRET_KEY_FILE=/run/secrets/confsync_key
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ Testé contre un faux serveur PVE (API + SSH) ; **pas encore testé sur un vrai
|
|||||||
| `sdn-vnet` | API `/cluster/sdn/vnets` | `sync` |
|
| `sdn-vnet` | API `/cluster/sdn/vnets` | `sync` |
|
||||||
| `sdn-subnet` | API `/cluster/sdn/vnets/{vnet}/subnets` | `sync` |
|
| `sdn-subnet` | API `/cluster/sdn/vnets/{vnet}/subnets` | `sync` |
|
||||||
|
|
||||||
Dépendances : un mot de passe exige son compte, un compte exige ses groupes ; le menu
|
Les comptes du realm pve et leurs mots de passe forment un seul élément à répliquer ;
|
||||||
« Éléments répliqués » les coche ensemble. Un parent absent de la cible et non répliqué
|
ils exigent leurs groupes, que le menu « Éléments répliqués » coche avec eux. Un parent absent de la cible et non répliqué
|
||||||
est une erreur bloquante.
|
est une erreur bloquante.
|
||||||
|
|
||||||
Hors périmètre : EVPN (et les zones simple, QinQ, faucet, avec leurs VNets, ignorées
|
Hors périmètre : EVPN (et les zones simple, QinQ, faucet, avec leurs VNets, ignorées
|
||||||
@@ -66,15 +66,10 @@ docker compose -f docker-compose.demo.yml up -d --build # même démo en conte
|
|||||||
docker compose up -d # production : données dans le volume /data
|
docker compose up -d # production : données dans le volume /data
|
||||||
```
|
```
|
||||||
|
|
||||||
Avant le premier `docker compose up`, créer la clé AES-256 des mots de passe en base
|
La clé AES-256 des mots de passe en base est créée au premier démarrage dans
|
||||||
(secret docker, à sauvegarder : sans elle, les mots de passe enregistrés sont perdus) :
|
`/data/secret.key` (à sauvegarder : sans elle, les mots de passe enregistrés sont perdus).
|
||||||
|
En production, on peut la garder hors du volume en secret docker (`CONFSYNC_SECRET_KEY_FILE`,
|
||||||
```sh
|
voir les commentaires de `docker-compose.yml`). Port publié : `CONFSYNC_PORT` (`.env`, 8080 par défaut).
|
||||||
mkdir -p secrets
|
|
||||||
openssl rand -hex 32 > secrets/confsync_key # 64 caractères hex (32 octets)
|
|
||||||
chmod 600 secrets/confsync_key
|
|
||||||
sudo chown 10001 secrets/confsync_key # lisible par l'utilisateur du conteneur
|
|
||||||
```
|
|
||||||
|
|
||||||
Le mode démo démarre un primaire PVE 9.1, deux cibles avec des écarts (dont un mapping
|
Le mode démo démarre un primaire PVE 9.1, deux cibles avec des écarts (dont un mapping
|
||||||
manquant, bloquant) et un cluster PVE 8 refusé. Comptes `admin`, `operateur` et `lecteur`,
|
manquant, bloquant) et un cluster PVE 8 refusé. Comptes `admin`, `operateur` et `lecteur`,
|
||||||
@@ -97,6 +92,8 @@ devient administrateur** : ne pas l'exposer avant. Alternative : `CONFSYNC_ADMIN
|
|||||||
chaque champ la politique, la valeur du primaire, le mapping, la valeur actuelle et
|
chaque champ la politique, la valeur du primaire, le mapping, la valeur actuelle et
|
||||||
la valeur après réplication. Les hachés sont masqués (`<masqué xxxxxx>`).
|
la valeur après réplication. Les hachés sont masqués (`<masqué xxxxxx>`).
|
||||||
- **Dry-run / Répliquer** par objet (avec ses parents à créer), par cible ou global.
|
- **Dry-run / Répliquer** par objet (avec ses parents à créer), par cible ou global.
|
||||||
|
Dans le dry-run, chaque élément peut être exclu de l'exécution (« Ne pas répliquer ») ;
|
||||||
|
le dry-run est alors relancé sans lui, sauf ce dont les autres ont besoin.
|
||||||
La réplication passe toujours par un dry-run : le serveur recalcule le plan et refuse
|
La réplication passe toujours par un dry-run : le serveur recalcule le plan et refuse
|
||||||
s'il a changé depuis (empreinte du plan). Avec des changements SDN, il faut taper le
|
s'il a changé depuis (empreinte du plan). Avec des changements SDN, il faut taper le
|
||||||
nom de la cible (celui du primaire en global). Une seule réplication à la fois.
|
nom de la cible (celui du primaire en global). Une seule réplication à la fois.
|
||||||
|
|||||||
+12
-10
@@ -1,5 +1,5 @@
|
|||||||
# Interface web :
|
# Interface web :
|
||||||
# docker compose up -d # http://<hôte>:8080
|
# docker compose up -d # http://<hôte>:${CONFSYNC_PORT:-8080}
|
||||||
# Base vide : la page de connexion crée le premier administrateur (ne pas exposer
|
# Base vide : la page de connexion crée le premier administrateur (ne pas exposer
|
||||||
# l'instance avant), sauf si CONFSYNC_ADMIN_PASSWORD(_FILE) est fourni.
|
# l'instance avant), sauf si CONFSYNC_ADMIN_PASSWORD(_FILE) est fourni.
|
||||||
# Clusters, mots de passe root (chiffrés en base) et réglages : tout se fait dans l'UI.
|
# Clusters, mots de passe root (chiffrés en base) et réglages : tout se fait dans l'UI.
|
||||||
@@ -10,10 +10,12 @@ services:
|
|||||||
container_name: pve-confsync
|
container_name: pve-confsync
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080" # ou derrière Traefik (HTTPS : cookies Secure)
|
- "${CONFSYNC_PORT:-8080}:8080" # port publié (.env) ; ou derrière Traefik (HTTPS : cookies Secure)
|
||||||
environment:
|
environment:
|
||||||
TZ: ${TZ:-Europe/Paris}
|
TZ: ${TZ:-Europe/Paris}
|
||||||
CONFSYNC_SECRET_KEY_FILE: /run/secrets/confsync_key # clé AES des mots de passe en base, hors volume /data
|
# Clé AES des mots de passe en base : créée au premier démarrage dans /data/secret.key.
|
||||||
|
# Pour la garder hors du volume /data (recommandé en production) : secret docker ci-dessous.
|
||||||
|
# CONFSYNC_SECRET_KEY_FILE: /run/secrets/confsync_key
|
||||||
# CONFSYNC_ADMIN_PASSWORD_FILE: /run/secrets/confsync_admin
|
# CONFSYNC_ADMIN_PASSWORD_FILE: /run/secrets/confsync_admin
|
||||||
# OIDC_ISSUER: https://auth.example.org/application/o/confsync/
|
# OIDC_ISSUER: https://auth.example.org/application/o/confsync/
|
||||||
# OIDC_CLIENT_ID: confsync
|
# OIDC_CLIENT_ID: confsync
|
||||||
@@ -25,15 +27,15 @@ services:
|
|||||||
- path: .env
|
- path: .env
|
||||||
required: false
|
required: false
|
||||||
volumes:
|
volumes:
|
||||||
- confsync-data:/data # SQLite, session.key
|
- confsync-data:/data # SQLite, secret.key, session.key
|
||||||
secrets:
|
# secrets:
|
||||||
- confsync_key
|
# - confsync_key
|
||||||
|
|
||||||
# Fichier chmod 600, propriétaire 10001, monté dans /run/secrets/confsync_key.
|
# Optionnel : clé fournie en secret docker (fichier chmod 600, propriétaire 10001).
|
||||||
# mkdir -p secrets && openssl rand -hex 32 > secrets/confsync_key
|
# mkdir -p secrets && openssl rand -hex 32 > secrets/confsync_key
|
||||||
secrets:
|
# secrets:
|
||||||
confsync_key:
|
# confsync_key:
|
||||||
file: ./secrets/confsync_key
|
# file: ./secrets/confsync_key
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
confsync-data:
|
confsync-data:
|
||||||
@@ -72,6 +72,36 @@ var KindDeps = map[model.Kind][]model.Kind{
|
|||||||
model.KindUser: {model.KindGroup},
|
model.KindUser: {model.KindGroup},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KindBundles are kinds chosen as one item: the accounts of the pve realm go
|
||||||
|
// with their passwords (the API holds the accounts, shadow.cfg the passwords).
|
||||||
|
var KindBundles = [][]model.Kind{{model.KindUser, model.KindPassword}}
|
||||||
|
|
||||||
|
// Bundle adds to a list of kinds the other kinds of their bundles, in the
|
||||||
|
// order of model.Kinds.
|
||||||
|
func Bundle(kinds []model.Kind) []model.Kind {
|
||||||
|
in := map[model.Kind]bool{}
|
||||||
|
for _, k := range kinds {
|
||||||
|
in[k] = true
|
||||||
|
}
|
||||||
|
for _, b := range KindBundles {
|
||||||
|
for _, k := range b {
|
||||||
|
if in[k] {
|
||||||
|
for _, x := range b {
|
||||||
|
in[x] = true
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := []model.Kind{}
|
||||||
|
for _, k := range model.Kinds {
|
||||||
|
if in[k] {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Sensitive is implemented by adapters whose fields must never be shown nor
|
// Sensitive is implemented by adapters whose fields must never be shown nor
|
||||||
// journaled (password hashes): they are replaced by Redact.
|
// journaled (password hashes): they are replaced by Redact.
|
||||||
type Sensitive interface {
|
type Sensitive interface {
|
||||||
@@ -102,7 +132,7 @@ func Redact(v any) any {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
sum := sha256.Sum256([]byte(s))
|
sum := sha256.Sum256([]byte(s))
|
||||||
return "<masqué " + hex.EncodeToString(sum[:3]) + ">"
|
return "<hidden " + hex.EncodeToString(sum[:3]) + ">"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parents returns the keys of the objects o needs on the target.
|
// Parents returns the keys of the objects o needs on the target.
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func Start(ctx context.Context, st *store.Store) (*Clusters, error) {
|
|||||||
ref.Roles["ConfSyncOps"] = "Sys.Audit,VM.Audit,VM.PowerMgmt"
|
ref.Roles["ConfSyncOps"] = "Sys.Audit,VM.Audit,VM.PowerMgmt"
|
||||||
ref.Users["alice@pve"] = map[string]any{"enable": 1, "firstname": "Alice", "email": "alice@example.org", "groups": "admins"}
|
ref.Users["alice@pve"] = map[string]any{"enable": 1, "firstname": "Alice", "email": "alice@example.org", "groups": "admins"}
|
||||||
ref.Users["bob@pve"] = map[string]any{"enable": 1, "firstname": "Bob", "email": "bob@example.org", "groups": "ops", "expire": 1893456000}
|
ref.Users["bob@pve"] = map[string]any{"enable": 1, "firstname": "Bob", "email": "bob@example.org", "groups": "ops", "expire": 1893456000}
|
||||||
ref.Users["carol@pam"] = map[string]any{"enable": 1, "comment": "compte Linux, non répliqué"}
|
ref.Users["carol@pam"] = map[string]any{"enable": 1, "comment": "Linux account, not replicated"}
|
||||||
ref.Shadow["alice"] = "$5$Kd1Xa9$Q8yJdemoAliceHashOnlyForTheDemo0123456789AB"
|
ref.Shadow["alice"] = "$5$Kd1Xa9$Q8yJdemoAliceHashOnlyForTheDemo0123456789AB"
|
||||||
ref.Shadow["bob"] = "$5$Pq7Zr2$W3vLdemoBobHashOnlyForTheDemo0123456789ABCDE"
|
ref.Shadow["bob"] = "$5$Pq7Zr2$W3vLdemoBobHashOnlyForTheDemo0123456789ABCDE"
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ func ApplyWithLog(ctx context.Context, c *pve.Client, plan Plan, notify func(Ste
|
|||||||
err := c.Post(ctx, "cluster/sdn/lock", nil, &token)
|
err := c.Post(ctx, "cluster/sdn/lock", nil, &token)
|
||||||
res.log("sdn lock", err)
|
res.log("sdn lock", err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
res.Err = fmt.Errorf("SDN lock (pending changes on the client?): %w", err)
|
res.Err = fmt.Errorf("SDN lock (pending changes on the target?): %w", err)
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
rollback := func(cause error) {
|
rollback := func(cause error) {
|
||||||
|
|||||||
@@ -158,13 +158,13 @@ func MakePlan(cfg *config.Config, cl *config.Client, ref, actual []model.Object)
|
|||||||
o, exists := cur[r.ID]
|
o, exists := cur[r.ID]
|
||||||
if !exists {
|
if !exists {
|
||||||
if a.Singleton() {
|
if a.Singleton() {
|
||||||
plan.Errors = append(plan.Errors, fmt.Sprintf("%s: not found on client", r.Key()))
|
plan.Errors = append(plan.Errors, fmt.Sprintf("%s: not found on the target", r.Key()))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, f := range a.Required(model.Object{Kind: k, ID: r.ID, Fields: desired}) {
|
for _, f := range a.Required(model.Object{Kind: k, ID: r.ID, Fields: desired}) {
|
||||||
if _, ok := desired[f]; !ok {
|
if _, ok := desired[f]; !ok {
|
||||||
plan.Errors = append(plan.Errors,
|
plan.Errors = append(plan.Errors,
|
||||||
fmt.Sprintf("%s: field %q required for creation, add it to the mapping of client %s", r.Key(), f, cl.Name))
|
fmt.Sprintf("%s: field %q required for creation, add it to the mapping of target %s", r.Key(), f, cl.Name))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
plan.Changes = append(plan.Changes, Change{Kind: k, ID: r.ID, Op: OpCreate, Set: desired,
|
plan.Changes = append(plan.Changes, Change{Kind: k, ID: r.ID, Op: OpCreate, Set: desired,
|
||||||
|
|||||||
@@ -151,3 +151,37 @@ func (p Plan) Digest() string {
|
|||||||
sum := sha256.Sum256(b)
|
sum := sha256.Sum256(b)
|
||||||
return hex.EncodeToString(sum[:12])
|
return hex.EncodeToString(sum[:12])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip removes from a plan the changes of the kinds left out of a replication
|
||||||
|
// ("not synchronized" in the dry-run), then keeps what the remaining changes
|
||||||
|
// need as Filter does: a group to create for an account stays in the plan.
|
||||||
|
// keys narrows the plan to some objects first (empty: every object).
|
||||||
|
func Skip(p Plan, keys []string, skip []model.Kind) Plan {
|
||||||
|
if len(skip) == 0 {
|
||||||
|
return Filter(p, keys)
|
||||||
|
}
|
||||||
|
out := map[model.Kind]bool{}
|
||||||
|
for _, k := range skip {
|
||||||
|
out[k] = true
|
||||||
|
}
|
||||||
|
if len(keys) == 0 {
|
||||||
|
for _, c := range p.Changes {
|
||||||
|
keys = append(keys, c.Key())
|
||||||
|
}
|
||||||
|
for _, e := range p.Errors {
|
||||||
|
if k, ok := ErrorKey(e); ok {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var kept []string
|
||||||
|
for _, k := range keys {
|
||||||
|
if kind, _, _ := strings.Cut(k, "/"); !out[model.Kind(kind)] {
|
||||||
|
kept = append(kept, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(kept) == 0 {
|
||||||
|
return Plan{Client: p.Client, Warnings: p.Warnings}
|
||||||
|
}
|
||||||
|
return Filter(p, kept)
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"pve-confsync/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Items left out of a run ("do not replicate") leave the plan, except what the
|
||||||
|
// remaining changes need: the group to create for a new account.
|
||||||
|
func TestSkipKinds(t *testing.T) {
|
||||||
|
p := Plan{Client: "c", Changes: []Change{
|
||||||
|
{Kind: model.KindGroup, ID: "ops", Op: OpCreate, Set: map[string]any{"comment": "Ops"}},
|
||||||
|
{Kind: model.KindGroup, ID: "dev", Op: OpUpdate, Set: map[string]any{"comment": "Dev"}},
|
||||||
|
{Kind: model.KindUser, ID: "bob@pve", Op: OpCreate, Set: map[string]any{"groups": "ops"}},
|
||||||
|
{Kind: model.KindPassword, ID: "bob@pve", Op: OpCreate, Set: map[string]any{"hash": "$5$x"}},
|
||||||
|
{Kind: model.KindVNet, ID: "v1", Op: OpUpdate, Set: map[string]any{"alias": "a"}},
|
||||||
|
}, Errors: []string{"sdn-zone/z1: field \"bridge\" required for creation, add it to the mapping of target c"}}
|
||||||
|
keys := func(p Plan) []string {
|
||||||
|
var out []string
|
||||||
|
for _, c := range p.Changes {
|
||||||
|
out = append(out, c.Key())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
got := Skip(p, nil, []model.Kind{model.KindGroup, model.KindZone, model.KindVNet})
|
||||||
|
if want := []string{"pve-group/ops", "pve-user/bob@pve", "pve-password/bob@pve"}; !reflect.DeepEqual(keys(got), want) {
|
||||||
|
t.Fatalf("changes: %v, want %v", keys(got), want)
|
||||||
|
}
|
||||||
|
if len(got.Errors) != 0 {
|
||||||
|
t.Fatalf("the error of a skipped kind must not block: %v", got.Errors)
|
||||||
|
}
|
||||||
|
if got := Skip(p, nil, model.Kinds); len(got.Changes) != 0 || len(got.Errors) != 0 {
|
||||||
|
t.Fatalf("everything skipped: %+v", got)
|
||||||
|
}
|
||||||
|
if got := Skip(p, nil, nil); len(got.Changes) != len(p.Changes) || len(got.Errors) != 1 {
|
||||||
|
t.Fatalf("nothing skipped: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
-45
@@ -88,11 +88,15 @@ type selection struct {
|
|||||||
Targets []string `json:"targets"` // global scope: the targets (empty: all of them)
|
Targets []string `json:"targets"` // global scope: the targets (empty: all of them)
|
||||||
Client string `json:"client"` // client and object scopes
|
Client string `json:"client"` // client and object scopes
|
||||||
Objects []string `json:"objects"` // object scope: "kind/id"
|
Objects []string `json:"objects"` // object scope: "kind/id"
|
||||||
|
Skip []model.Kind `json:"skip"` // kinds left out of this run ("not synchronized")
|
||||||
Digests map[string]string `json:"digests"` // apply: digest of each reviewed plan
|
Digests map[string]string `json:"digests"` // apply: digest of each reviewed plan
|
||||||
Confirm string `json:"confirm"` // apply with SDN changes: target name (primary name for global)
|
Confirm string `json:"confirm"` // apply with SDN changes: target name (primary name for global)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sel *selection) check() error {
|
func (sel *selection) check() error {
|
||||||
|
if len(sel.Skip) > 0 {
|
||||||
|
sel.Skip = adapter.Bundle(sel.Skip)
|
||||||
|
}
|
||||||
switch sel.Scope {
|
switch sel.Scope {
|
||||||
case "global":
|
case "global":
|
||||||
sel.Client, sel.Objects = "", nil
|
sel.Client, sel.Objects = "", nil
|
||||||
@@ -137,7 +141,7 @@ func (s *Server) plan(ctx context.Context, sel selection) (*config.Config, *serv
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
st := service.Analyze(rctx, cfg, sel.clients())
|
st := service.Analyze(rctx, cfg, sel.clients())
|
||||||
s.remember(st, sel.all())
|
s.remember(st, sel.all())
|
||||||
return cfg, st, st.Plans(sel.Objects), nil
|
return cfg, st, st.Plans(sel.Objects, sel.Skip), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type runDetail struct {
|
type runDetail struct {
|
||||||
@@ -190,16 +194,25 @@ func planClients(plans []service.ClientPlan) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func target(sel selection) string {
|
func target(sel selection) string {
|
||||||
switch sel.Scope {
|
var t string
|
||||||
case "client":
|
switch {
|
||||||
return sel.Client
|
case sel.Scope == "client":
|
||||||
case "object":
|
t = sel.Client
|
||||||
return sel.Client + ": " + strings.Join(sel.Objects, ", ")
|
case sel.Scope == "object":
|
||||||
|
t = sel.Client + ": " + strings.Join(sel.Objects, ", ")
|
||||||
|
case len(sel.Targets) > 0:
|
||||||
|
t = "targets " + strings.Join(sel.Targets, ", ")
|
||||||
|
default:
|
||||||
|
t = "all targets"
|
||||||
}
|
}
|
||||||
if len(sel.Targets) > 0 {
|
if len(sel.Skip) > 0 {
|
||||||
return "cibles " + strings.Join(sel.Targets, ", ")
|
var ks []string
|
||||||
|
for _, k := range sel.Skip {
|
||||||
|
ks = append(ks, string(k))
|
||||||
|
}
|
||||||
|
t += " (not replicated: " + strings.Join(ks, ", ") + ")"
|
||||||
}
|
}
|
||||||
return "toutes les cibles"
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
// dryRunSummary counts the changes: "3 changes on 2 clients", or the errors.
|
// dryRunSummary counts the changes: "3 changes on 2 clients", or the errors.
|
||||||
@@ -217,11 +230,11 @@ func dryRunSummary(plans []service.ClientPlan) (string, string) {
|
|||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case blocked > 0:
|
case blocked > 0:
|
||||||
return store.RunBlocked, fmt.Sprintf("%d changement(s) sur %d cible(s), %d cible(s) en erreur", changes, clients, blocked)
|
return store.RunBlocked, fmt.Sprintf("%d change(s) on %d target(s), %d target(s) in error", changes, clients, blocked)
|
||||||
case changes == 0:
|
case changes == 0:
|
||||||
return store.RunNoop, "aucun écart"
|
return store.RunNoop, "no difference"
|
||||||
}
|
}
|
||||||
return store.RunOK, fmt.Sprintf("%d changement(s) sur %d cible(s)", changes, clients)
|
return store.RunOK, fmt.Sprintf("%d change(s) on %d target(s)", changes, clients)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleApply recomputes the plans, checks that they are the ones reviewed in the
|
// handleApply recomputes the plans, checks that they are the ones reviewed in the
|
||||||
@@ -239,7 +252,7 @@ func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
id := identity(r)
|
id := identity(r)
|
||||||
if !s.applyMu.TryLock() {
|
if !s.applyMu.TryLock() {
|
||||||
writeError(w, http.StatusConflict, "une synchronisation est déjà en cours", "busy")
|
writeError(w, http.StatusConflict, "a replication is already running", "busy")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
started := false
|
started := false
|
||||||
@@ -266,8 +279,8 @@ func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusConflict, p.Client+": "+strings.Join(p.Plan.Errors, "; "), "plan")
|
writeError(w, http.StatusConflict, p.Client+": "+strings.Join(p.Plan.Errors, "; "), "plan")
|
||||||
return
|
return
|
||||||
case sel.Digests[p.Client] != p.Digest:
|
case sel.Digests[p.Client] != p.Digest:
|
||||||
s.audit(r, id, "apply", target(sel), false, p.Client+": l'état a changé depuis le dry-run")
|
s.audit(r, id, "apply", target(sel), false, p.Client+": the state changed since the dry-run")
|
||||||
writeJSON(w, http.StatusConflict, map[string]any{"error": p.Client + ": l'état a changé depuis le dry-run, relancer le dry-run",
|
writeJSON(w, http.StatusConflict, map[string]any{"error": p.Client + ": the state changed since the dry-run, run it again",
|
||||||
"code": "stale", "plans": plans})
|
"code": "stale", "plans": plans})
|
||||||
return
|
return
|
||||||
case p.Plan.Empty():
|
case p.Plan.Empty():
|
||||||
@@ -277,7 +290,7 @@ func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
|
|||||||
todo = append(todo, p)
|
todo = append(todo, p)
|
||||||
}
|
}
|
||||||
if len(todo) == 0 {
|
if len(todo) == 0 {
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"run": 0, "summary": "aucun écart"})
|
writeJSON(w, http.StatusOK, map[string]any{"run": 0, "summary": "no difference"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if sdn {
|
if sdn {
|
||||||
@@ -286,7 +299,7 @@ func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
|
|||||||
want = cfg.Reference.Name
|
want = cfg.Reference.Name
|
||||||
}
|
}
|
||||||
if sel.Confirm != want {
|
if sel.Confirm != want {
|
||||||
writeError(w, http.StatusBadRequest, "changements SDN : confirmation requise ("+want+")", "confirm")
|
writeError(w, http.StatusBadRequest, "SDN changes: confirmation required ("+want+")", "confirm")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -333,7 +346,7 @@ func (s *Server) runApply(r *http.Request, id Identity, runID int64, sel selecti
|
|||||||
res := det.Results[p.Client]
|
res := det.Results[p.Client]
|
||||||
res.Status = store.RunRunning
|
res.Status = store.RunRunning
|
||||||
save(store.RunRunning, fmt.Sprintf("%s en cours", p.Client))
|
save(store.RunRunning, fmt.Sprintf("%s en cours", p.Client))
|
||||||
err := st.Apply(ctx, p, sel.Objects, func(step engine.Step) {
|
err := st.Apply(ctx, p, sel.Objects, sel.Skip, func(step engine.Step) {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
res.Steps = append(res.Steps, step)
|
res.Steps = append(res.Steps, step)
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
@@ -347,14 +360,14 @@ func (s *Server) runApply(r *http.Request, id Identity, runID int64, sel selecti
|
|||||||
} else {
|
} else {
|
||||||
done++
|
done++
|
||||||
res.Status = store.RunOK
|
res.Status = store.RunOK
|
||||||
s.audit(r, id, "apply", p.Client, true, fmt.Sprintf("%d changement(s), vérifié", len(p.Plan.Changes)))
|
s.audit(r, id, "apply", p.Client, true, fmt.Sprintf("%d change(s), verified", len(p.Plan.Changes)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
status := store.RunOK
|
status := store.RunOK
|
||||||
summary := fmt.Sprintf("%d cible(s) synchronisée(s) et vérifiée(s)", done)
|
summary := fmt.Sprintf("%d target(s) replicated and verified", done)
|
||||||
if failed > 0 {
|
if failed > 0 {
|
||||||
status = store.RunError
|
status = store.RunError
|
||||||
summary = fmt.Sprintf("%d cible(s) en échec, %d synchronisée(s)", failed, done)
|
summary = fmt.Sprintf("%d target(s) failed, %d replicated", failed, done)
|
||||||
}
|
}
|
||||||
save(status, summary)
|
save(status, summary)
|
||||||
s.invalidate()
|
s.invalidate()
|
||||||
@@ -423,12 +436,12 @@ func (s *Server) handleSaveCluster(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
err := s.store.SaveCluster(r.Context(), old, c)
|
err := s.store.SaveCluster(r.Context(), old, c)
|
||||||
detail := fmt.Sprintf("url=%s rôle=%s tls=%s ssh=%s:%d", c.URL, c.Role, c.TLS, c.SSHHost, c.SSHPort)
|
detail := fmt.Sprintf("url=%s role=%s tls=%s ssh=%s:%d", c.URL, c.Role, c.TLS, c.SSHHost, c.SSHPort)
|
||||||
if c.Password != "" {
|
if c.Password != "" {
|
||||||
detail += " mot de passe=modifié"
|
detail += " password=changed"
|
||||||
}
|
}
|
||||||
if old != "" && old != c.Name {
|
if old != "" && old != c.Name {
|
||||||
detail += " renommé depuis " + old
|
detail += " renamed from " + old
|
||||||
}
|
}
|
||||||
s.audit(r, Identity{}, "cluster-save", c.Name, err == nil, errText(err, detail))
|
s.audit(r, Identity{}, "cluster-save", c.Name, err == nil, errText(err, detail))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -546,10 +559,10 @@ func (s *Server) handleCheckCluster(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
detail := res.Error
|
detail := res.Error
|
||||||
if fp != "" {
|
if fp != "" {
|
||||||
detail += " certificat TLS enregistré " + fp
|
detail += " TLS certificate recorded " + fp
|
||||||
}
|
}
|
||||||
if res.SSHHostKey != "" {
|
if res.SSHHostKey != "" {
|
||||||
detail += " clé SSH enregistrée " + res.SSHFingerprint
|
detail += " SSH key recorded " + res.SSHFingerprint
|
||||||
}
|
}
|
||||||
s.audit(r, Identity{}, "cluster-check", name, res.OK, strings.TrimSpace(detail))
|
s.audit(r, Identity{}, "cluster-check", name, res.OK, strings.TrimSpace(detail))
|
||||||
if fp != "" || res.SSHHostKey != "" {
|
if fp != "" || res.SSHHostKey != "" {
|
||||||
@@ -563,7 +576,7 @@ func (s *Server) handleCheckCluster(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) handleForget(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleForget(w http.ResponseWriter, r *http.Request) {
|
||||||
name := r.PathValue("name")
|
name := r.PathValue("name")
|
||||||
err := s.store.Forget(r.Context(), name)
|
err := s.store.Forget(r.Context(), name)
|
||||||
s.audit(r, Identity{}, "cluster-forget", name, err == nil, errText(err, "empreinte TLS et clé SSH oubliées"))
|
s.audit(r, Identity{}, "cluster-forget", name, err == nil, errText(err, "TLS fingerprint and SSH key forgotten"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusNotFound, err.Error(), "notfound")
|
writeError(w, http.StatusNotFound, err.Error(), "notfound")
|
||||||
return
|
return
|
||||||
@@ -578,21 +591,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusInternalServerError, err.Error(), "store")
|
writeError(w, http.StatusInternalServerError, err.Error(), "store")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"settings": st, "allKinds": model.Kinds, "deps": adapter.KindDeps, "defaults": defaultPolicies()})
|
writeJSON(w, http.StatusOK, map[string]any{"settings": st, "allKinds": model.Kinds, "deps": adapter.KindDeps, "bundles": adapter.KindBundles})
|
||||||
}
|
|
||||||
|
|
||||||
// defaultPolicies describes the built-in policies, shown in the settings.
|
|
||||||
func defaultPolicies() map[model.Kind]string {
|
|
||||||
return map[model.Kind]string{
|
|
||||||
model.KindDatacenter: "tag-style, registered-tags, user-tag-access, next-id : sync ; le reste : ignore ; webauthn, u2f, http_proxy : jamais",
|
|
||||||
model.KindGroup: "sync (commentaire ; les membres viennent des comptes)",
|
|
||||||
model.KindRole: "sync (rôles personnalisés ; les rôles intégrés ne sont pas touchés)",
|
|
||||||
model.KindUser: "sync (comptes du realm pve : nom, e-mail, groupes, expiration, activation…)",
|
|
||||||
model.KindPassword: "sync (hachage copié par SSH dans /etc/pve/priv/shadow.cfg, jamais affiché)",
|
|
||||||
model.KindZone: "nodes, bridge, peers, fabric, dns, reversedns, dnszone : override (mapping de la cible) ; le reste : sync",
|
|
||||||
model.KindVNet: "sync",
|
|
||||||
model.KindSubnet: "sync",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleSaveSettings(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSaveSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -601,6 +600,7 @@ func (s *Server) handleSaveSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, err.Error(), "request")
|
writeError(w, http.StatusBadRequest, err.Error(), "request")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
st.Kinds = adapter.Bundle(st.Kinds)
|
||||||
cfg := &config.Config{Kinds: st.Kinds, Objects: st.Objects, Policies: st.Policies, Prune: st.Prune,
|
cfg := &config.Config{Kinds: st.Kinds, Objects: st.Objects, Policies: st.Policies, Prune: st.Prune,
|
||||||
Reference: config.Cluster{Name: "\x00ref"}}
|
Reference: config.Cluster{Name: "\x00ref"}}
|
||||||
err := cfg.Validate()
|
err := cfg.Validate()
|
||||||
@@ -611,7 +611,7 @@ func (s *Server) handleSaveSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
err = s.store.SaveSettings(r.Context(), st)
|
err = s.store.SaveSettings(r.Context(), st)
|
||||||
}
|
}
|
||||||
b, _ := json.Marshal(st)
|
b, _ := json.Marshal(st)
|
||||||
s.audit(r, Identity{}, "settings-save", "synchronisation", err == nil, errText(err, string(b)))
|
s.audit(r, Identity{}, "settings-save", "replication", err == nil, errText(err, string(b)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error(), "invalid")
|
writeError(w, http.StatusBadRequest, err.Error(), "invalid")
|
||||||
return
|
return
|
||||||
@@ -645,9 +645,9 @@ func (s *Server) handleSaveUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
name := r.PathValue("name")
|
name := r.PathValue("name")
|
||||||
err := s.store.SaveUser(r.Context(), name, req.Role, req.Password)
|
err := s.store.SaveUser(r.Context(), name, req.Role, req.Password)
|
||||||
detail := "rôle " + req.Role
|
detail := "role " + req.Role
|
||||||
if req.Password != "" {
|
if req.Password != "" {
|
||||||
detail += ", mot de passe défini"
|
detail += ", password set"
|
||||||
}
|
}
|
||||||
s.audit(r, Identity{}, "user-save", name, err == nil, errText(err, detail))
|
s.audit(r, Identity{}, "user-save", name, err == nil, errText(err, detail))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -344,7 +344,7 @@ func (a *Auth) handleSetup(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
id := Identity{User: u.Name, Role: u.Role, Mode: "local"}
|
id := Identity{User: u.Name, Role: u.Role, Mode: "local"}
|
||||||
log.Printf("auth: first administrator %q created from %s", u.Name, clientIP(r))
|
log.Printf("auth: first administrator %q created from %s", u.Name, clientIP(r))
|
||||||
a.audit(r, id, "setup", u.Name, true, "premier administrateur créé")
|
a.audit(r, id, "setup", u.Name, true, "first administrator created")
|
||||||
a.setSession(w, r, session{User: u.Name, Role: u.Role, Mode: "local", Epoch: u.Epoch})
|
a.setSession(w, r, session{User: u.Name, Role: u.Role, Mode: "local", Epoch: u.Epoch})
|
||||||
http.Redirect(w, r, "/#clusters", http.StatusSeeOther)
|
http.Redirect(w, r, "/#clusters", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
"net/http/cookiejar"
|
||||||
@@ -276,6 +277,11 @@ func TestFromScratch(t *testing.T) {
|
|||||||
if code, d := adm.do("POST", "/api/clusters/ref/role", map[string]any{"role": "primary"}, true); code != http.StatusOK {
|
if code, d := adm.do("POST", "/api/clusters/ref/role", map[string]any{"role": "primary"}, true); code != http.StatusOK {
|
||||||
t.Fatalf("role: %d %v", code, d)
|
t.Fatalf("role: %d %v", code, d)
|
||||||
}
|
}
|
||||||
|
// accounts left out of the run ("do not replicate"): their passwords go with them
|
||||||
|
skipped := map[string]any{"scope": "global", "targets": []string{"tgt"}, "skip": []string{"pve-user", "pve-group", "pve-role"}}
|
||||||
|
if code, d := adm.do("POST", "/api/dryrun", skipped, true); code != http.StatusOK || strings.Contains(fmt.Sprint(d["plans"]), "pve-") {
|
||||||
|
t.Fatalf("dry-run without the accounts: %d %v", code, d)
|
||||||
|
}
|
||||||
sel := map[string]any{"scope": "global", "targets": []string{"tgt"}}
|
sel := map[string]any{"scope": "global", "targets": []string{"tgt"}}
|
||||||
code, d := adm.do("POST", "/api/dryrun", sel, true)
|
code, d := adm.do("POST", "/api/dryrun", sel, true)
|
||||||
if code != http.StatusOK {
|
if code != http.StatusOK {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func TestCheckSSHHostKeyMismatch(t *testing.T) {
|
|||||||
SSHHostKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(other)))}
|
SSHHostKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(other)))}
|
||||||
|
|
||||||
res := CheckCluster(context.Background(), cl, false, false)
|
res := CheckCluster(context.Background(), cl, false, false)
|
||||||
if res.OK || !res.SSHMismatch || !res.API || res.SSHHostKey != "" || !strings.Contains(res.Error, "clé d'hôte") {
|
if res.OK || !res.SSHMismatch || !res.API || res.SSHHostKey != "" || !strings.Contains(res.Error, "host key") {
|
||||||
t.Fatalf("mismatch expected: %+v", res)
|
t.Fatalf("mismatch expected: %+v", res)
|
||||||
}
|
}
|
||||||
res = CheckCluster(context.Background(), cl, false, true)
|
res = CheckCluster(context.Background(), cl, false, true)
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ type ClientPlan struct {
|
|||||||
|
|
||||||
// Plans returns the plans of the given clients restricted to the given objects
|
// Plans returns the plans of the given clients restricted to the given objects
|
||||||
// (empty: everything), as a dry-run shows them and an apply checks them.
|
// (empty: everything), as a dry-run shows them and an apply checks them.
|
||||||
func (st *State) Plans(keys []string) []ClientPlan {
|
func (st *State) Plans(keys []string, skip []model.Kind) []ClientPlan {
|
||||||
var out []ClientPlan
|
var out []ClientPlan
|
||||||
for _, c := range st.Clients {
|
for _, c := range st.Clients {
|
||||||
cp := ClientPlan{Client: c.Name}
|
cp := ClientPlan{Client: c.Name}
|
||||||
@@ -325,7 +325,7 @@ func (st *State) Plans(keys []string) []ClientPlan {
|
|||||||
case c.Error != "":
|
case c.Error != "":
|
||||||
cp.Error = c.Error
|
cp.Error = c.Error
|
||||||
default:
|
default:
|
||||||
cp.Plan = engine.Filter(c.Plan, keys)
|
cp.Plan = engine.Skip(c.Plan, keys, skip)
|
||||||
cp.Digest = cp.Plan.Digest()
|
cp.Digest = cp.Plan.Digest()
|
||||||
cp.SDN = cp.Plan.HasSDN()
|
cp.SDN = cp.Plan.HasSDN()
|
||||||
}
|
}
|
||||||
@@ -336,7 +336,7 @@ func (st *State) Plans(keys []string) []ClientPlan {
|
|||||||
|
|
||||||
// Apply writes one plan on a client, then checks that the selected objects are
|
// Apply writes one plan on a client, then checks that the selected objects are
|
||||||
// in line with the reference (same reference objects as the plan).
|
// in line with the reference (same reference objects as the plan).
|
||||||
func (st *State) Apply(ctx context.Context, cp ClientPlan, keys []string, log func(engine.Step)) error {
|
func (st *State) Apply(ctx context.Context, cp ClientPlan, keys []string, skip []model.Kind, log func(engine.Step)) error {
|
||||||
cl, ok := st.cfg.Client(cp.Client)
|
cl, ok := st.cfg.Client(cp.Client)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("unknown client %q", cp.Client)
|
return fmt.Errorf("unknown client %q", cp.Client)
|
||||||
@@ -351,7 +351,7 @@ func (st *State) Apply(ctx context.Context, cp ClientPlan, keys []string, log fu
|
|||||||
}
|
}
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
err = engine.Verify(ctx, c, func(actual []model.Object) engine.Plan {
|
err = engine.Verify(ctx, c, func(actual []model.Object) engine.Plan {
|
||||||
return engine.Filter(engine.MakePlan(st.cfg, cl, st.ref, actual), keys)
|
return engine.Skip(engine.MakePlan(st.cfg, cl, st.ref, actual), keys, skip)
|
||||||
}, st.cfg.Kinds)
|
}, st.cfg.Kinds)
|
||||||
s := engine.Step{At: start.UTC(), Action: "verify"}
|
s := engine.Step{At: start.UTC(), Action: "verify"}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -394,7 +394,7 @@ func CheckCluster(ctx context.Context, cl config.Cluster, pinTLS, trust bool) Ch
|
|||||||
cl.Fingerprint, res.TLSFingerprint = fp, fp
|
cl.Fingerprint, res.TLSFingerprint = fp, fp
|
||||||
}
|
}
|
||||||
if cl.Insecure {
|
if cl.Insecure {
|
||||||
res.Warnings = append(res.Warnings, "certificat TLS non vérifié (labo uniquement)")
|
res.Warnings = append(res.Warnings, "TLS certificate not verified (lab only)")
|
||||||
}
|
}
|
||||||
key := cl.SSHHostKey
|
key := cl.SSHHostKey
|
||||||
cl.SSHHostKey = "" // API only for now
|
cl.SSHHostKey = "" // API only for now
|
||||||
@@ -433,7 +433,7 @@ func CheckCluster(ctx context.Context, cl config.Cluster, pinTLS, trust bool) Ch
|
|||||||
if strings.Contains(err.Error(), "host key mismatch") {
|
if strings.Contains(err.Error(), "host key mismatch") {
|
||||||
_, fp, _ := sshx.Scan(ctx, o)
|
_, fp, _ := sshx.Scan(ctx, o)
|
||||||
res.SSHMismatch, res.SSHFingerprint = true, fp
|
res.SSHMismatch, res.SSHFingerprint = true, fp
|
||||||
return fail(fmt.Errorf("SSH: le nœud %s présente la clé d'hôte %s au lieu de %s enregistrée", o.Host, fp, sshx.Fingerprint(key)))
|
return fail(fmt.Errorf("SSH: node %s presents host key %s instead of the recorded %s", o.Host, fp, sshx.Fingerprint(key)))
|
||||||
}
|
}
|
||||||
return fail(fmt.Errorf("SSH: %w", err))
|
return fail(fmt.Errorf("SSH: %w", err))
|
||||||
}
|
}
|
||||||
|
|||||||
+170
-55
@@ -15,7 +15,7 @@
|
|||||||
kindAria: "Type d'objet", statusAria: 'Statut', modeAria: 'Mode', dryAll: 'Dry-run', syncAll: 'Répliquer',
|
kindAria: "Type d'objet", statusAria: 'Statut', modeAria: 'Mode', dryAll: 'Dry-run', syncAll: 'Répliquer',
|
||||||
kindMenu: 'Éléments répliqués', targetMenu: 'Cibles', allTargets: 'Toutes les cibles', noTarget: 'Aucune cible sélectionnée.',
|
kindMenu: 'Éléments répliqués', targetMenu: 'Cibles', allTargets: 'Toutes les cibles', noTarget: 'Aucune cible sélectionnée.',
|
||||||
kindMenuRO: 'Seuls les administrateurs changent les éléments répliqués.',
|
kindMenuRO: 'Seuls les administrateurs changent les éléments répliqués.',
|
||||||
kindMenuHelp: 'Un compte a besoin de ses groupes, un mot de passe de son compte : ils sont cochés ensemble.',
|
kindMenuHelp: 'Les comptes du realm pve sont répliqués avec leurs mots de passe et ont besoin de leurs groupes : ils sont cochés ensemble.',
|
||||||
kindGroups: { dc: 'Datacenter', access: 'Comptes et droits (realm pve)', sdn: 'SDN' },
|
kindGroups: { dc: 'Datacenter', access: 'Comptes et droits (realm pve)', sdn: 'SDN' },
|
||||||
setupClusters: 'Base vide : ajoutez vos clusters (URL et mot de passe root), puis choisissez le primaire.', setupClustersBtn: 'Ajouter des clusters',
|
setupClusters: 'Base vide : ajoutez vos clusters (URL et mot de passe root), puis choisissez le primaire.', setupClustersBtn: 'Ajouter des clusters',
|
||||||
setupPrimary: 'Aucun cluster primaire : choisissez la source de la réplication dans la vue Clusters.', setupPrimaryBtn: 'Choisir le primaire',
|
setupPrimary: 'Aucun cluster primaire : choisissez la source de la réplication dans la vue Clusters.', setupPrimaryBtn: 'Choisir le primaire',
|
||||||
@@ -25,25 +25,25 @@
|
|||||||
colField: 'Champ', colValue: 'Valeur', colPolicy: 'Politique', colInclude: 'Inclure (motifs)', colExclude: 'Exclure (motifs)',
|
colField: 'Champ', colValue: 'Valeur', colPolicy: 'Politique', colInclude: 'Inclure (motifs)', colExclude: 'Exclure (motifs)',
|
||||||
colRole: 'Rôle', colPassword: 'Nouveau mot de passe', colLastLogin: 'Dernière connexion',
|
colRole: 'Rôle', colPassword: 'Nouveau mot de passe', colLastLogin: 'Dernière connexion',
|
||||||
more: 'Plus', toTop: 'Haut de page', close: 'Fermer', cancel: 'Annuler', save: 'Enregistrer', delete: 'Supprimer',
|
more: 'Plus', toTop: 'Haut de page', close: 'Fermer', cancel: 'Annuler', save: 'Enregistrer', delete: 'Supprimer',
|
||||||
applyBtn: 'Synchroniser', addRow: '+ Ajouter',
|
applyBtn: 'Répliquer', addRow: '+ Ajouter',
|
||||||
kinds: {
|
kinds: {
|
||||||
'datacenter-options': 'Options datacenter', 'pve-group': 'Groupes', 'pve-role': 'Rôles personnalisés',
|
'datacenter-options': 'Options datacenter', 'pve-group': 'Groupes', 'pve-role': 'Rôles personnalisés',
|
||||||
'pve-user': 'Comptes du realm pve', 'pve-password': 'Mots de passe (SSH)',
|
'pve-user': 'Comptes du realm pve et mots de passe', 'pve-password': 'Mots de passe du realm pve',
|
||||||
'sdn-zone': 'Zones SDN (VLAN, VXLAN)', 'sdn-vnet': 'VNets', 'sdn-subnet': 'Subnets',
|
'sdn-zone': 'Zones SDN (VLAN, VXLAN)', 'sdn-vnet': 'VNets', 'sdn-subnet': 'Subnets',
|
||||||
},
|
},
|
||||||
kindShort: {
|
kindShort: {
|
||||||
'datacenter-options': 'options', 'pve-group': 'groupe', 'pve-role': 'rôle', 'pve-user': 'compte', 'pve-password': 'mot de passe',
|
'datacenter-options': 'options', 'pve-group': 'groupe', 'pve-role': 'rôle', 'pve-user': 'compte', 'pve-password': 'mdp',
|
||||||
'sdn-zone': 'zone', 'sdn-vnet': 'vnet', 'sdn-subnet': 'subnet',
|
'sdn-zone': 'zone', 'sdn-vnet': 'vnet', 'sdn-subnet': 'subnet',
|
||||||
},
|
},
|
||||||
allKinds: 'Tous les types',
|
allKinds: 'Tous les types',
|
||||||
st: { same: 'Conforme', create: 'À créer', update: 'À modifier', delete: 'À supprimer', extra: 'Hors primaire', error: 'Erreur' },
|
st: { same: 'Conforme', create: 'À créer', update: 'À modifier', delete: 'À supprimer', extra: 'Hors primaire', error: 'Erreur' },
|
||||||
filterDiff: 'Écarts et erreurs', filterAll: 'Tous les objets', filterSame: 'Conformes', filterError: 'Erreurs',
|
filterDiff: 'Écarts et erreurs', filterAll: 'Tous les objets', filterSame: 'Conformes', filterError: 'Erreurs',
|
||||||
reference: 'PRIMAIRE', unreadable: 'illisible', inLine: 'conforme', nodes: '{n} nœud(s)',
|
reference: 'PRIMAIRE', unreadable: 'illisible', inLine: 'conforme', nodes: '{n} nœud(s)',
|
||||||
readAt: 'lu à {t} ({ms} ms)', dryClient: 'Dry-run de la cible', syncClient: 'Synchroniser la cible',
|
readAt: 'lu à {t} ({ms} ms)', dryClient: 'Dry-run de la cible', syncClient: 'Répliquer vers la cible',
|
||||||
dryObj: 'Dry-run de cet objet', syncObj: 'Synchroniser cet objet',
|
dryObj: 'Dry-run de cet objet', syncObj: 'Répliquer cet objet',
|
||||||
refError: 'Primaire illisible', clientError: 'Cible illisible', planError: 'Bloquant', warning: 'Avertissement',
|
refError: 'Primaire illisible', clientError: 'Cible illisible', planError: 'Bloquant', warning: 'Avertissement',
|
||||||
fieldsN: '{n} champ(s)', objectsN: '{n} objet(s)', noFields: 'aucun champ',
|
fieldsN: '{n} champ(s)', objectsN: '{n} objet(s)', noFields: 'aucun champ',
|
||||||
thField: 'Champ', thPolicy: 'Politique', thRef: 'Primaire', thMapping: 'Mapping cible', thCurrent: 'Cible actuelle', thTarget: 'Après synchro',
|
thField: 'Champ', thPolicy: 'Politique', thRef: 'Primaire', thMapping: 'Mapping cible', thCurrent: 'Cible actuelle', thTarget: 'Après réplication',
|
||||||
emptyOk: 'Aucun écart pour cette cible.', emptyOkHint: 'La cible est conforme au primaire pour les éléments répliqués.',
|
emptyOk: 'Aucun écart pour cette cible.', emptyOkHint: 'La cible est conforme au primaire pour les éléments répliqués.',
|
||||||
emptyFilter: 'Aucun objet ne correspond aux filtres.', emptyFilterHint: 'Changez le type, le statut ou la recherche.',
|
emptyFilter: 'Aucun objet ne correspond aux filtres.', emptyFilterHint: 'Changez le type, le statut ou la recherche.',
|
||||||
noClient: 'Aucune cible.', noClientHint: 'Ajoutez des clusters cibles dans la vue Clusters.',
|
noClient: 'Aucune cible.', noClientHint: 'Ajoutez des clusters cibles dans la vue Clusters.',
|
||||||
@@ -53,28 +53,28 @@
|
|||||||
planSaved: 'Dry-run enregistré dans le journal (n° {id}).', changesN: '{n} changement(s)', sdnBadge: 'SDN',
|
planSaved: 'Dry-run enregistré dans le journal (n° {id}).', changesN: '{n} changement(s)', sdnBadge: 'SDN',
|
||||||
sdnConfirm: "Les changements SDN sont appliqués sous verrou puis validés : le réseau de tous les nœuds de {c} est rechargé. Tapez « {w} » pour confirmer.",
|
sdnConfirm: "Les changements SDN sont appliqués sous verrou puis validés : le réseau de tous les nœuds de {c} est rechargé. Tapez « {w} » pour confirmer.",
|
||||||
sdnConfirmGlobal: "Des changements SDN rechargent le réseau des nœuds de plusieurs cibles. Tapez le nom du cluster primaire « {w} » pour confirmer.",
|
sdnConfirmGlobal: "Des changements SDN rechargent le réseau des nœuds de plusieurs cibles. Tapez le nom du cluster primaire « {w} » pour confirmer.",
|
||||||
applyNow: 'Appliquer', applying: 'Synchronisation en cours…', applyDone: 'Synchronisation terminée', stale: "L'état a changé depuis le dry-run : relancez-le.",
|
applyNow: 'Appliquer', applying: 'Réplication en cours…', applyDone: 'Réplication terminée', stale: "L'état a changé depuis le dry-run : relancez-le.",
|
||||||
relaunch: 'Relancer le dry-run', runN: 'Journal n° {id}', was: 'était',
|
relaunch: 'Relancer le dry-run', runN: 'Journal n° {id}', was: 'était',
|
||||||
res: { pending: 'en attente', running: 'en cours', ok: 'OK', error: 'échec', noop: 'rien à faire', blocked: 'bloqué' },
|
res: { pending: 'en attente', running: 'en cours', ok: 'OK', error: 'échec', noop: 'rien à faire', blocked: 'bloqué' },
|
||||||
mode: { 'dry-run': 'dry-run', apply: 'synchro' }, scope: { global: 'cibles', client: 'cible', object: 'objet' },
|
mode: { 'dry-run': 'dry-run', apply: 'réplication' }, scope: { global: 'cibles', client: 'cible', object: 'objet' },
|
||||||
allModes: 'Tous les modes', onlyApply: 'Synchronisations', onlyDry: 'Dry-runs',
|
allModes: 'Tous les modes', onlyApply: 'Réplications', onlyDry: 'Dry-runs',
|
||||||
noRuns: 'Aucune exécution.', noAudit: 'Aucun évènement.',
|
noRuns: 'Aucune exécution.', noAudit: 'Aucun évènement.',
|
||||||
action: {
|
action: {
|
||||||
setup: 'premier administrateur', login: 'connexion', logout: 'déconnexion', 'dry-run': 'dry-run', 'apply-start': 'synchro lancée', apply: 'synchro',
|
setup: 'premier administrateur', login: 'connexion', logout: 'déconnexion', 'dry-run': 'dry-run', 'apply-start': 'réplication lancée', apply: 'réplication',
|
||||||
'cluster-save': 'cluster enregistré', 'cluster-delete': 'cluster supprimé', 'cluster-check': 'test du cluster',
|
'cluster-save': 'cluster enregistré', 'cluster-delete': 'cluster supprimé', 'cluster-check': 'test du cluster',
|
||||||
'cluster-role': 'rôle du cluster', 'cluster-forget': 'empreintes oubliées', 'settings-save': 'paramètres',
|
'cluster-role': 'rôle du cluster', 'cluster-forget': 'empreintes oubliées', 'settings-save': 'paramètres',
|
||||||
'user-save': 'utilisateur enregistré', 'user-delete': 'utilisateur supprimé', 'password-change': 'mot de passe',
|
'user-save': 'utilisateur enregistré', 'user-delete': 'utilisateur supprimé', 'password-change': 'mot de passe',
|
||||||
},
|
},
|
||||||
tabSync: 'Synchronisation', tabUsers: 'Utilisateurs', tabAccount: 'Compte', tabAppearance: 'Apparence',
|
tabSync: 'Réplication', tabUsers: 'Utilisateurs', tabAccount: 'Compte', tabAppearance: 'Apparence',
|
||||||
roNote: 'Lecture seule : seuls les administrateurs modifient ces paramètres.',
|
roNote: 'Lecture seule : seuls les administrateurs modifient ces paramètres.',
|
||||||
addCluster: '+ Ajouter un cluster', addBulk: '+ Ajouter une liste', testAll: 'Tout tester',
|
addCluster: '+ Ajouter un cluster', addBulk: '+ Ajouter une liste', testAll: 'Tout tester',
|
||||||
clustersHelp: "Chaque cluster est joint avec root@pam : l'API pour la configuration, SSH (root, même mot de passe) pour ce que l'API ne couvre pas (mots de passe du realm pve). Le certificat TLS et la clé d'hôte SSH sont enregistrés au premier test, puis vérifiés. Le primaire n'est jamais modifié. Les mots de passe sont chiffrés en base (AES-256-GCM) et jamais réaffichés.",
|
clustersHelp: "Chaque cluster est joint avec root@pam : l'API pour la configuration, SSH (root, même mot de passe) pour ce que l'API ne couvre pas (mots de passe du realm pve). Le certificat TLS et la clé d'hôte SSH sont enregistrés au premier test, puis vérifiés. Le primaire n'est jamais modifié. Les mots de passe sont chiffrés en base (AES-256-GCM) et jamais réaffichés.",
|
||||||
test: 'Tester', edit: 'Modifier', testing: 'Test en cours…', testOk: 'Accès OK', role: { admin: 'admin', operator: 'opérateur', viewer: 'lecture seule' },
|
test: 'Tester', edit: 'Modifier', testing: 'Test en cours…', testOk: 'Accès OK', role: { admin: 'admin', operator: 'opérateur', viewer: 'lecture seule' },
|
||||||
crole: { primary: 'primaire', target: 'cible', disabled: 'inactif' }, makePrimary: 'Définir comme primaire',
|
crole: { primary: 'primaire', target: 'cible', disabled: 'désactivé' }, makePrimary: 'Définir comme primaire',
|
||||||
neverTested: 'jamais testé', testedAt: 'testé le {t}', pinned: 'enregistré : {l}', trustKey: 'Faire confiance à la nouvelle clé',
|
neverTested: 'jamais testé', testedAt: 'testé le {t}', pinned: 'enregistré : {l}', trustKey: 'Faire confiance à la nouvelle clé',
|
||||||
confirmTrust: "La clé d'hôte SSH de {c} a changé ({fp}). Ne l'acceptez que si le nœud a été réinstallé ou remplacé. Faire confiance à cette clé ?",
|
confirmTrust: "La clé d'hôte SSH de {c} a changé ({fp}). Ne l'acceptez que si le nœud a été réinstallé ou remplacé. Faire confiance à cette clé ?",
|
||||||
newCluster: 'Nouveau cluster', editCluster: 'Cluster {c}', cName: 'Nom', cRole: 'Rôle',
|
newCluster: 'Nouveau cluster', editCluster: 'Cluster {c}', cName: 'Nom', cRole: 'Rôle',
|
||||||
rolePrimary: 'Primaire', roleTarget: 'Cible', roleDisabled: 'Inactif',
|
rolePrimary: 'Primaire', roleTarget: 'Cible', roleDisabled: 'Désactivé',
|
||||||
cUser: 'Utilisateur', cPassword: 'Mot de passe root', cTLS: 'TLS', cFp: 'Empreinte', cTimeout: 'Délai',
|
cUser: 'Utilisateur', cPassword: 'Mot de passe root', cTLS: 'TLS', cFp: 'Empreinte', cTimeout: 'Délai',
|
||||||
cSSHHost: 'Nœud SSH', cSSHPort: 'Port SSH', cSSHKey: "Clé d'hôte SSH", sshKeyNone: 'enregistrée au premier test',
|
cSSHHost: 'Nœud SSH', cSSHPort: 'Port SSH', cSSHKey: "Clé d'hôte SSH", sshKeyNone: 'enregistrée au premier test',
|
||||||
fpPh: 'enregistrée au premier test', sshHostPh: "hôte de l'URL",
|
fpPh: 'enregistrée au premier test', sshHostPh: "hôte de l'URL",
|
||||||
@@ -86,20 +86,31 @@
|
|||||||
confirmDelete: 'Supprimer le cluster {c} ? Son mapping est perdu.', confirmDeleteUser: "Supprimer l'utilisateur {u} ?",
|
confirmDelete: 'Supprimer le cluster {c} ? Son mapping est perdu.', confirmDeleteUser: "Supprimer l'utilisateur {u} ?",
|
||||||
bulkTitle: 'Ajouter une liste de clusters', bulkHelp: "Un cluster par ligne : « nom URL », ou l'URL seule (le nom est alors l'hôte). Tous sont ajoutés comme cibles avec le même mot de passe root, puis testés ; choisissez ensuite le primaire.",
|
bulkTitle: 'Ajouter une liste de clusters', bulkHelp: "Un cluster par ligne : « nom URL », ou l'URL seule (le nom est alors l'hôte). Tous sont ajoutés comme cibles avec le même mot de passe root, puis testés ; choisissez ensuite le primaire.",
|
||||||
bulkPassword: 'Mot de passe root', bulkGo: 'Ajouter et tester', bulkBadLine: 'ligne illisible : {l}',
|
bulkPassword: 'Mot de passe root', bulkGo: 'Ajouter et tester', bulkBadLine: 'ligne illisible : {l}',
|
||||||
kindsTitle: 'Éléments répliqués', kindsHelp: 'Un compte a besoin de ses groupes, un mot de passe de son compte. Hors périmètre : EVPN, firewall, storage, ACL, jobs, tokens API, comptes pam et des autres realms.',
|
kindsTitle: 'Éléments répliqués', kindsHelp: 'Les comptes du realm pve vont avec leurs mots de passe et ont besoin de leurs groupes. Hors périmètre : EVPN, firewall, storage, ACL, jobs, tokens API, comptes pam et des autres realms.',
|
||||||
pruneTitle: 'Suppression', pruneLabel: 'Supprimer sur les cibles les objets absents du primaire',
|
pruneTitle: 'Suppression', pruneLabel: 'Supprimer sur les cibles les objets absents du primaire',
|
||||||
pruneHelp: 'Désactivé par défaut (mode additif + mise à jour). Activé, les dry-runs listent aussi les suppressions.',
|
pruneHelp: 'Désactivé par défaut (mode additif + mise à jour). Activé, les dry-runs listent aussi les suppressions.',
|
||||||
selTitle: 'Sélection des objets', selHelp: "Motifs glob sur l'identifiant, séparés par des virgules (ex. lab*, test-?, *@pve). Vide : tout.",
|
selTitle: 'Sélection des objets', selHelp: "Motifs glob sur l'identifiant, séparés par des virgules (ex. lab*, test-?, *@pve). Vide : tout.",
|
||||||
polTitle: 'Politiques par champ', polHelp: 'sync : copie du primaire ; ignore : jamais lu ni écrit ; override : valeur fournie par le mapping de la cible. Défauts :',
|
polTitle: 'Politiques par champ', polHelp: 'sync : copie du primaire ; ignore : jamais lu ni écrit ; override : valeur fournie par le mapping de la cible. Défauts :',
|
||||||
unsaved: 'Modifications non enregistrées', saved: 'Enregistré', saveFailed: 'Échec : {e}',
|
unsaved: 'Modifications non enregistrées', saved: 'Enregistré', saveFailed: 'Échec : {e}',
|
||||||
usersHelp: "Comptes locaux de pve-confsync (toujours disponibles, même si le fournisseur OIDC est indisponible). Rôles : admin (tout), opérateur (dry-run et synchronisation), lecture seule. Les utilisateurs OIDC reçoivent leur rôle d'après leurs groupes.",
|
usersHelp: "Comptes locaux de pve-confsync (toujours disponibles, même si le fournisseur OIDC est indisponible). Rôles : admin (tout), opérateur (dry-run et réplication), lecture seule. Les utilisateurs OIDC reçoivent leur rôle d'après leurs groupes.",
|
||||||
addUser: '+ Ajouter un utilisateur', never: 'jamais', pwdKeep: 'inchangé', pwdRequired: 'obligatoire',
|
addUser: '+ Ajouter un utilisateur', never: 'jamais', pwdKeep: 'inchangé', pwdRequired: 'obligatoire',
|
||||||
accountTitle: 'Mon compte', pwdTitle: 'Changer de mot de passe', pwdOld: 'Mot de passe actuel', pwdNew: 'Nouveau', pwdNew2: 'Confirmation',
|
accountTitle: 'Mon compte', pwdTitle: 'Changer de mot de passe', pwdOld: 'Mot de passe actuel', pwdNew: 'Nouveau', pwdNew2: 'Confirmation',
|
||||||
pwdBtn: 'Changer le mot de passe', pwdMismatch: 'Les deux mots de passe diffèrent.', pwdDone: 'Mot de passe changé.', pwdOidc: 'Mot de passe géré par le fournisseur d\'identité.',
|
pwdBtn: 'Changer le mot de passe', pwdMismatch: 'Les deux mots de passe diffèrent.', pwdDone: 'Mot de passe changé.', pwdOidc: 'Mot de passe géré par le fournisseur d\'identité.',
|
||||||
accUser: 'Utilisateur', accRole: 'Rôle', accMode: 'Connexion', modeLocal: 'compte local', modeOidc: 'OIDC',
|
accUser: 'Utilisateur', accRole: 'Rôle', accMode: 'Connexion', modeLocal: 'compte local', modeOidc: 'OIDC',
|
||||||
language: 'Langue', themeTitle: 'Thème', themeSystem: 'Système', themeLight: 'Clair', themeDark: 'Sombre',
|
language: 'Langue', themeTitle: 'Thème', themeSystem: 'Système', themeLight: 'Clair', themeDark: 'Sombre',
|
||||||
density: 'Densité', densityNormal: 'Normale', densityCompact: 'Compacte',
|
density: 'Densité', densityNormal: 'Normale', densityCompact: 'Compacte',
|
||||||
noRights: 'Votre rôle ne permet pas de synchroniser.', errorPrefix: 'Erreur : ',
|
noRights: 'Votre rôle ne permet pas de répliquer.', errorPrefix: 'Erreur : ',
|
||||||
|
planKinds: 'Éléments de cette exécution', kindOn: 'Répliquer', kindOff: 'Ne pas répliquer',
|
||||||
|
planKindsHelp: "Un élément exclu n'est pas écrit, sauf ce dont les autres ont besoin (un groupe à créer pour un compte). Changer un choix relance le dry-run.",
|
||||||
|
redacted: 'masqué',
|
||||||
|
polDef: {
|
||||||
|
'datacenter-options': 'tag-style, registered-tags, user-tag-access, next-id : sync ; le reste : ignore ; webauthn, u2f, http_proxy : jamais',
|
||||||
|
'pve-group': 'sync (commentaire ; les membres viennent des comptes)',
|
||||||
|
'pve-role': 'sync (rôles personnalisés ; les rôles intégrés ne sont pas touchés)',
|
||||||
|
'pve-user': 'sync (nom, e-mail, groupes, expiration, activation… ; mot de passe : hachage copié par SSH dans /etc/pve/priv/shadow.cfg, jamais affiché)',
|
||||||
|
'sdn-zone': 'nodes, bridge, peers, fabric, dns, reversedns, dnszone : override (mapping de la cible) ; le reste : sync',
|
||||||
|
'sdn-vnet': 'sync', 'sdn-subnet': 'sync',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
demo: 'DEMO', searchPh: 'Filter objects (name, field, value)', refresh: 'Refresh',
|
demo: 'DEMO', searchPh: 'Filter objects (name, field, value)', refresh: 'Refresh',
|
||||||
@@ -108,7 +119,7 @@
|
|||||||
kindAria: 'Object kind', statusAria: 'Status', modeAria: 'Mode', dryAll: 'Dry-run', syncAll: 'Replicate',
|
kindAria: 'Object kind', statusAria: 'Status', modeAria: 'Mode', dryAll: 'Dry-run', syncAll: 'Replicate',
|
||||||
kindMenu: 'Replicated items', targetMenu: 'Targets', allTargets: 'All targets', noTarget: 'No target selected.',
|
kindMenu: 'Replicated items', targetMenu: 'Targets', allTargets: 'All targets', noTarget: 'No target selected.',
|
||||||
kindMenuRO: 'Only administrators change the replicated items.',
|
kindMenuRO: 'Only administrators change the replicated items.',
|
||||||
kindMenuHelp: 'An account needs its groups, a password its account: they are checked together.',
|
kindMenuHelp: 'The accounts of the pve realm are replicated with their passwords and need their groups: they are checked together.',
|
||||||
kindGroups: { dc: 'Datacenter', access: 'Accounts and permissions (pve realm)', sdn: 'SDN' },
|
kindGroups: { dc: 'Datacenter', access: 'Accounts and permissions (pve realm)', sdn: 'SDN' },
|
||||||
setupClusters: 'Empty database: add your clusters (URL and root password), then choose the primary.', setupClustersBtn: 'Add clusters',
|
setupClusters: 'Empty database: add your clusters (URL and root password), then choose the primary.', setupClustersBtn: 'Add clusters',
|
||||||
setupPrimary: 'No primary cluster: choose the source of the replication in the Clusters view.', setupPrimaryBtn: 'Choose the primary',
|
setupPrimary: 'No primary cluster: choose the source of the replication in the Clusters view.', setupPrimaryBtn: 'Choose the primary',
|
||||||
@@ -118,25 +129,25 @@
|
|||||||
colField: 'Field', colValue: 'Value', colPolicy: 'Policy', colInclude: 'Include (patterns)', colExclude: 'Exclude (patterns)',
|
colField: 'Field', colValue: 'Value', colPolicy: 'Policy', colInclude: 'Include (patterns)', colExclude: 'Exclude (patterns)',
|
||||||
colRole: 'Role', colPassword: 'New password', colLastLogin: 'Last login',
|
colRole: 'Role', colPassword: 'New password', colLastLogin: 'Last login',
|
||||||
more: 'More', toTop: 'Back to top', close: 'Close', cancel: 'Cancel', save: 'Save', delete: 'Delete',
|
more: 'More', toTop: 'Back to top', close: 'Close', cancel: 'Cancel', save: 'Save', delete: 'Delete',
|
||||||
applyBtn: 'Synchronize', addRow: '+ Add',
|
applyBtn: 'Replicate', addRow: '+ Add',
|
||||||
kinds: {
|
kinds: {
|
||||||
'datacenter-options': 'Datacenter options', 'pve-group': 'Groups', 'pve-role': 'Custom roles',
|
'datacenter-options': 'Datacenter options', 'pve-group': 'Groups', 'pve-role': 'Custom roles',
|
||||||
'pve-user': 'pve realm accounts', 'pve-password': 'Passwords (SSH)',
|
'pve-user': 'Accounts of the pve realm and passwords', 'pve-password': 'Passwords of the pve realm',
|
||||||
'sdn-zone': 'SDN zones (VLAN, VXLAN)', 'sdn-vnet': 'VNets', 'sdn-subnet': 'Subnets',
|
'sdn-zone': 'SDN zones (VLAN, VXLAN)', 'sdn-vnet': 'VNets', 'sdn-subnet': 'Subnets',
|
||||||
},
|
},
|
||||||
kindShort: {
|
kindShort: {
|
||||||
'datacenter-options': 'options', 'pve-group': 'group', 'pve-role': 'role', 'pve-user': 'account', 'pve-password': 'password',
|
'datacenter-options': 'options', 'pve-group': 'group', 'pve-role': 'role', 'pve-user': 'account', 'pve-password': 'passwd',
|
||||||
'sdn-zone': 'zone', 'sdn-vnet': 'vnet', 'sdn-subnet': 'subnet',
|
'sdn-zone': 'zone', 'sdn-vnet': 'vnet', 'sdn-subnet': 'subnet',
|
||||||
},
|
},
|
||||||
allKinds: 'All kinds',
|
allKinds: 'All kinds',
|
||||||
st: { same: 'In sync', create: 'Create', update: 'Update', delete: 'Delete', extra: 'Not on primary', error: 'Error' },
|
st: { same: 'In sync', create: 'Create', update: 'Update', delete: 'Delete', extra: 'Not on primary', error: 'Error' },
|
||||||
filterDiff: 'Differences and errors', filterAll: 'All objects', filterSame: 'In sync', filterError: 'Errors',
|
filterDiff: 'Differences and errors', filterAll: 'All objects', filterSame: 'In sync', filterError: 'Errors',
|
||||||
reference: 'PRIMARY', unreadable: 'unreadable', inLine: 'in sync', nodes: '{n} node(s)',
|
reference: 'PRIMARY', unreadable: 'unreadable', inLine: 'in sync', nodes: '{n} node(s)',
|
||||||
readAt: 'read at {t} ({ms} ms)', dryClient: 'Target dry-run', syncClient: 'Synchronize target',
|
readAt: 'read at {t} ({ms} ms)', dryClient: 'Target dry-run', syncClient: 'Replicate to target',
|
||||||
dryObj: 'Dry-run this object', syncObj: 'Synchronize this object',
|
dryObj: 'Dry-run this object', syncObj: 'Replicate this object',
|
||||||
refError: 'Primary unreadable', clientError: 'Target unreadable', planError: 'Blocking', warning: 'Warning',
|
refError: 'Primary unreadable', clientError: 'Target unreadable', planError: 'Blocking', warning: 'Warning',
|
||||||
fieldsN: '{n} field(s)', objectsN: '{n} object(s)', noFields: 'no field',
|
fieldsN: '{n} field(s)', objectsN: '{n} object(s)', noFields: 'no field',
|
||||||
thField: 'Field', thPolicy: 'Policy', thRef: 'Primary', thMapping: 'Target mapping', thCurrent: 'Target now', thTarget: 'After sync',
|
thField: 'Field', thPolicy: 'Policy', thRef: 'Primary', thMapping: 'Target mapping', thCurrent: 'Target now', thTarget: 'After replication',
|
||||||
emptyOk: 'No difference for this target.', emptyOkHint: 'The target is in line with the primary for the replicated items.',
|
emptyOk: 'No difference for this target.', emptyOkHint: 'The target is in line with the primary for the replicated items.',
|
||||||
emptyFilter: 'No object matches the filters.', emptyFilterHint: 'Change the kind, the status or the search.',
|
emptyFilter: 'No object matches the filters.', emptyFilterHint: 'Change the kind, the status or the search.',
|
||||||
noClient: 'No target.', noClientHint: 'Add target clusters in the Clusters view.',
|
noClient: 'No target.', noClientHint: 'Add target clusters in the Clusters view.',
|
||||||
@@ -146,19 +157,19 @@
|
|||||||
planSaved: 'Dry-run saved in the journal (#{id}).', changesN: '{n} change(s)', sdnBadge: 'SDN',
|
planSaved: 'Dry-run saved in the journal (#{id}).', changesN: '{n} change(s)', sdnBadge: 'SDN',
|
||||||
sdnConfirm: 'SDN changes are written under lock then committed: the network of every node of {c} is reloaded. Type "{w}" to confirm.',
|
sdnConfirm: 'SDN changes are written under lock then committed: the network of every node of {c} is reloaded. Type "{w}" to confirm.',
|
||||||
sdnConfirmGlobal: 'SDN changes reload the network of the nodes of several targets. Type the name of the primary cluster "{w}" to confirm.',
|
sdnConfirmGlobal: 'SDN changes reload the network of the nodes of several targets. Type the name of the primary cluster "{w}" to confirm.',
|
||||||
applyNow: 'Apply', applying: 'Synchronization in progress…', applyDone: 'Synchronization finished', stale: 'The state changed since the dry-run: run it again.',
|
applyNow: 'Apply', applying: 'Replication in progress…', applyDone: 'Replication finished', stale: 'The state changed since the dry-run: run it again.',
|
||||||
relaunch: 'Run the dry-run again', runN: 'Journal #{id}', was: 'was',
|
relaunch: 'Run the dry-run again', runN: 'Journal #{id}', was: 'was',
|
||||||
res: { pending: 'pending', running: 'running', ok: 'OK', error: 'failed', noop: 'nothing to do', blocked: 'blocked' },
|
res: { pending: 'pending', running: 'running', ok: 'OK', error: 'failed', noop: 'nothing to do', blocked: 'blocked' },
|
||||||
mode: { 'dry-run': 'dry-run', apply: 'sync' }, scope: { global: 'targets', client: 'target', object: 'object' },
|
mode: { 'dry-run': 'dry-run', apply: 'replication' }, scope: { global: 'targets', client: 'target', object: 'object' },
|
||||||
allModes: 'All modes', onlyApply: 'Synchronizations', onlyDry: 'Dry-runs',
|
allModes: 'All modes', onlyApply: 'Replications', onlyDry: 'Dry-runs',
|
||||||
noRuns: 'No run yet.', noAudit: 'No event.',
|
noRuns: 'No run yet.', noAudit: 'No event.',
|
||||||
action: {
|
action: {
|
||||||
setup: 'first administrator', login: 'login', logout: 'logout', 'dry-run': 'dry-run', 'apply-start': 'sync started', apply: 'sync',
|
setup: 'first administrator', login: 'login', logout: 'logout', 'dry-run': 'dry-run', 'apply-start': 'replication started', apply: 'replication',
|
||||||
'cluster-save': 'cluster saved', 'cluster-delete': 'cluster deleted', 'cluster-check': 'cluster test',
|
'cluster-save': 'cluster saved', 'cluster-delete': 'cluster deleted', 'cluster-check': 'cluster test',
|
||||||
'cluster-role': 'cluster role', 'cluster-forget': 'fingerprints forgotten', 'settings-save': 'settings',
|
'cluster-role': 'cluster role', 'cluster-forget': 'fingerprints forgotten', 'settings-save': 'settings',
|
||||||
'user-save': 'user saved', 'user-delete': 'user deleted', 'password-change': 'password',
|
'user-save': 'user saved', 'user-delete': 'user deleted', 'password-change': 'password',
|
||||||
},
|
},
|
||||||
tabSync: 'Synchronization', tabUsers: 'Users', tabAccount: 'Account', tabAppearance: 'Appearance',
|
tabSync: 'Replication', tabUsers: 'Users', tabAccount: 'Account', tabAppearance: 'Appearance',
|
||||||
roNote: 'Read only: only administrators change these settings.',
|
roNote: 'Read only: only administrators change these settings.',
|
||||||
addCluster: '+ Add a cluster', addBulk: '+ Add a list', testAll: 'Test all',
|
addCluster: '+ Add a cluster', addBulk: '+ Add a list', testAll: 'Test all',
|
||||||
clustersHelp: 'Each cluster is reached as root@pam: the API for the configuration, SSH (root, same password) for what the API does not cover (passwords of the pve realm). The TLS certificate and the SSH host key are recorded on the first test, then checked. The primary is never written. Passwords are encrypted in the database (AES-256-GCM) and never shown again.',
|
clustersHelp: 'Each cluster is reached as root@pam: the API for the configuration, SSH (root, same password) for what the API does not cover (passwords of the pve realm). The TLS certificate and the SSH host key are recorded on the first test, then checked. The primary is never written. Passwords are encrypted in the database (AES-256-GCM) and never shown again.',
|
||||||
@@ -179,24 +190,39 @@
|
|||||||
confirmDelete: 'Delete cluster {c}? Its mapping is lost.', confirmDeleteUser: 'Delete user {u}?',
|
confirmDelete: 'Delete cluster {c}? Its mapping is lost.', confirmDeleteUser: 'Delete user {u}?',
|
||||||
bulkTitle: 'Add a list of clusters', bulkHelp: 'One cluster per line: "name URL", or the URL alone (the name is then the host). All are added as targets with the same root password, then tested; choose the primary afterwards.',
|
bulkTitle: 'Add a list of clusters', bulkHelp: 'One cluster per line: "name URL", or the URL alone (the name is then the host). All are added as targets with the same root password, then tested; choose the primary afterwards.',
|
||||||
bulkPassword: 'Root password', bulkGo: 'Add and test', bulkBadLine: 'unreadable line: {l}',
|
bulkPassword: 'Root password', bulkGo: 'Add and test', bulkBadLine: 'unreadable line: {l}',
|
||||||
kindsTitle: 'Replicated items', kindsHelp: 'An account needs its groups, a password its account. Out of scope: EVPN, firewall, storage, ACL, jobs, API tokens, accounts of pam and the other realms.',
|
kindsTitle: 'Replicated items', kindsHelp: 'The accounts of the pve realm go with their passwords and need their groups. Out of scope: EVPN, firewall, storage, ACL, jobs, API tokens, accounts of pam and the other realms.',
|
||||||
pruneTitle: 'Deletion', pruneLabel: 'Delete on the targets the objects absent from the primary',
|
pruneTitle: 'Deletion', pruneLabel: 'Delete on the targets the objects absent from the primary',
|
||||||
pruneHelp: 'Off by default (add + update). On, dry-runs also list the deletions.',
|
pruneHelp: 'Off by default (add + update). On, dry-runs also list the deletions.',
|
||||||
selTitle: 'Object selection', selHelp: 'Glob patterns on the id, comma separated (e.g. lab*, test-?, *@pve). Empty: everything.',
|
selTitle: 'Object selection', selHelp: 'Glob patterns on the id, comma separated (e.g. lab*, test-?, *@pve). Empty: everything.',
|
||||||
polTitle: 'Field policies', polHelp: 'sync: copied from the primary; ignore: never read nor written; override: value from the target mapping. Defaults:',
|
polTitle: 'Field policies', polHelp: 'sync: copied from the primary; ignore: never read nor written; override: value from the target mapping. Defaults:',
|
||||||
unsaved: 'Unsaved changes', saved: 'Saved', saveFailed: 'Failed: {e}',
|
unsaved: 'Unsaved changes', saved: 'Saved', saveFailed: 'Failed: {e}',
|
||||||
usersHelp: 'Local accounts of pve-confsync (always available, even when the OIDC provider is down). Roles: admin (everything), operator (dry-run and synchronization), read only. OIDC users get their role from their groups.',
|
usersHelp: 'Local accounts of pve-confsync (always available, even when the OIDC provider is down). Roles: admin (everything), operator (dry-run and replication), read only. OIDC users get their role from their groups.',
|
||||||
addUser: '+ Add a user', never: 'never', pwdKeep: 'unchanged', pwdRequired: 'required',
|
addUser: '+ Add a user', never: 'never', pwdKeep: 'unchanged', pwdRequired: 'required',
|
||||||
accountTitle: 'My account', pwdTitle: 'Change password', pwdOld: 'Current password', pwdNew: 'New', pwdNew2: 'Confirm',
|
accountTitle: 'My account', pwdTitle: 'Change password', pwdOld: 'Current password', pwdNew: 'New', pwdNew2: 'Confirm',
|
||||||
pwdBtn: 'Change password', pwdMismatch: 'The two passwords differ.', pwdDone: 'Password changed.', pwdOidc: 'Password managed by the identity provider.',
|
pwdBtn: 'Change password', pwdMismatch: 'The two passwords differ.', pwdDone: 'Password changed.', pwdOidc: 'Password managed by the identity provider.',
|
||||||
accUser: 'User', accRole: 'Role', accMode: 'Login', modeLocal: 'local account', modeOidc: 'OIDC',
|
accUser: 'User', accRole: 'Role', accMode: 'Login', modeLocal: 'local account', modeOidc: 'OIDC',
|
||||||
language: 'Language', themeTitle: 'Theme', themeSystem: 'System', themeLight: 'Light', themeDark: 'Dark',
|
language: 'Language', themeTitle: 'Theme', themeSystem: 'System', themeLight: 'Light', themeDark: 'Dark',
|
||||||
density: 'Density', densityNormal: 'Normal', densityCompact: 'Compact',
|
density: 'Density', densityNormal: 'Normal', densityCompact: 'Compact',
|
||||||
noRights: 'Your role cannot synchronize.', errorPrefix: 'Error: ',
|
noRights: 'Your role cannot replicate.', errorPrefix: 'Error: ',
|
||||||
|
planKinds: 'Items of this run', kindOn: 'Replicate', kindOff: 'Do not replicate',
|
||||||
|
planKindsHelp: 'An excluded item is not written, except what the others need (a group to create for an account). Changing a choice runs the dry-run again.',
|
||||||
|
redacted: 'hidden',
|
||||||
|
polDef: {
|
||||||
|
'datacenter-options': 'tag-style, registered-tags, user-tag-access, next-id: sync; the rest: ignore; webauthn, u2f, http_proxy: never',
|
||||||
|
'pve-group': 'sync (comment; the members come from the accounts)',
|
||||||
|
'pve-role': 'sync (custom roles; the built-in roles are not touched)',
|
||||||
|
'pve-user': 'sync (name, e-mail, groups, expiry, enabled…; password: hash copied over SSH into /etc/pve/priv/shadow.cfg, never shown)',
|
||||||
|
'sdn-zone': 'nodes, bridge, peers, fabric, dns, reversedns, dnszone: override (target mapping); the rest: sync',
|
||||||
|
'sdn-vnet': 'sync', 'sdn-subnet': 'sync',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const KINDS = ['datacenter-options', 'pve-group', 'pve-role', 'pve-user', 'pve-password', 'sdn-zone', 'sdn-vnet', 'sdn-subnet'];
|
const KINDS = ['datacenter-options', 'pve-group', 'pve-role', 'pve-user', 'pve-password', 'sdn-zone', 'sdn-vnet', 'sdn-subnet'];
|
||||||
const KIND_GROUPS = [['dc', ['datacenter-options']], ['access', ['pve-group', 'pve-role', 'pve-user', 'pve-password']], ['sdn', ['sdn-zone', 'sdn-vnet', 'sdn-subnet']]];
|
const KIND_GROUPS = [['dc', ['datacenter-options']], ['access', ['pve-group', 'pve-role', 'pve-user']], ['sdn', ['sdn-zone', 'sdn-vnet', 'sdn-subnet']]];
|
||||||
|
// kinds chosen as one item: the accounts of the pve realm with their passwords
|
||||||
|
const BUNDLES = { 'pve-user': ['pve-password'] };
|
||||||
|
const ITEMS = KINDS.filter((k) => !Object.values(BUNDLES).some((b) => b.includes(k)));
|
||||||
|
const bundled = (k) => [k, ...(BUNDLES[k] || [])];
|
||||||
const POLICIES = ['sync', 'ignore', 'override'];
|
const POLICIES = ['sync', 'ignore', 'override'];
|
||||||
const ROLES = ['admin', 'operator', 'viewer'];
|
const ROLES = ['admin', 'operator', 'viewer'];
|
||||||
|
|
||||||
@@ -277,7 +303,64 @@
|
|||||||
function fmtVal(v) {
|
function fmtVal(v) {
|
||||||
if (v == null) return '';
|
if (v == null) return '';
|
||||||
if (Array.isArray(v)) return '[' + v.join(', ') + ']';
|
if (Array.isArray(v)) return '[' + v.join(', ') + ']';
|
||||||
return String(v);
|
return String(v).replace(/^<hidden (\w+)>$/, (m, x) => '<' + t('redacted') + ' ' + x + '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// tm translates the messages written by the server (always in English) into
|
||||||
|
// the language of the page; unknown parts stay as they are.
|
||||||
|
const MSG_FR = [
|
||||||
|
[/^no difference$/, 'aucun écart'],
|
||||||
|
[/^(\d+) change\(s\) on (\d+) target\(s\), (\d+) target\(s\) in error$/, '$1 changement(s) sur $2 cible(s), $3 cible(s) en erreur'],
|
||||||
|
[/^(\d+) change\(s\) on (\d+) target\(s\)$/, '$1 changement(s) sur $2 cible(s)'],
|
||||||
|
[/^(\d+) change\(s\), verified$/, '$1 changement(s), vérifié'],
|
||||||
|
[/^(\d+) target\(s\) replicated and verified$/, '$1 cible(s) répliquée(s) et vérifiée(s)'],
|
||||||
|
[/^(\d+) target\(s\) failed, (\d+) replicated$/, '$1 cible(s) en échec, $2 répliquée(s)'],
|
||||||
|
[/^a replication is already running$/, 'une réplication est déjà en cours'],
|
||||||
|
[/: the state changed since the dry-run, run it again$/, " : l'état a changé depuis le dry-run, relancez-le"],
|
||||||
|
[/^SDN changes: confirmation required \((.*)\)$/, 'changements SDN : confirmation requise ($1)'],
|
||||||
|
[/^url: https:\/\/host:8006 expected$/, 'URL : https://hôte:8006 attendue'],
|
||||||
|
[/^user: root@pam expected.*$/, 'utilisateur : root@pam attendu (le même compte sert en SSH)'],
|
||||||
|
[/^ssh port: 1-65535 expected$/, 'port SSH : 1 à 65535'],
|
||||||
|
[/^select at least one kind$/, 'cochez au moins un élément'],
|
||||||
|
[/^name and url are required$/, 'nom et URL obligatoires'],
|
||||||
|
[/^the root password is required$/, 'le mot de passe root est obligatoire'],
|
||||||
|
[/^the CA certificate \(PEM\) is required$/, "le certificat d'autorité (PEM) est obligatoire"],
|
||||||
|
[/^cluster "(.*)" already exists$/, 'le cluster « $1 » existe déjà'],
|
||||||
|
[/^unknown cluster$/, 'cluster inconnu'],
|
||||||
|
[/^no primary cluster defined$/, 'aucun cluster primaire défini'],
|
||||||
|
[/^wrong current password$/, 'mot de passe actuel incorrect'],
|
||||||
|
[/^password managed by the identity provider$/, "mot de passe géré par le fournisseur d'identité"],
|
||||||
|
[/^invalid user name$/, "nom d'utilisateur invalide"],
|
||||||
|
[/^password: 8 characters minimum$/, 'mot de passe : 8 caractères minimum'],
|
||||||
|
[/^a password is required for a new user$/, 'un mot de passe est obligatoire pour un nouvel utilisateur'],
|
||||||
|
[/^this is the last local administrator$/, "c'est le dernier administrateur local"],
|
||||||
|
[/^role (\w+) required$/, 'rôle $1 requis'],
|
||||||
|
[/^TLS certificate not verified \(lab only\)$/, 'certificat TLS non vérifié (labo uniquement)'],
|
||||||
|
[/SSH: node (\S+) presents host key (\S+) instead of the recorded (\S+)/, "SSH : le nœud $1 présente la clé d'hôte $2 au lieu de $3 enregistrée"],
|
||||||
|
[/is not a PVE node/, "n'est pas un nœud PVE"],
|
||||||
|
[/node (\S+) runs PVE "([^"]*)", only (\d+)\.x is supported/, 'le nœud $1 est en PVE « $2 », seul $3.x est pris en charge'],
|
||||||
|
[/authentication of (\S+) failed/, "échec de l'authentification de $1"],
|
||||||
|
[/(\S+) has a second factor \(TFA\), not supported/, '$1 a un second facteur (TFA), non pris en charge'],
|
||||||
|
[/certificate fingerprint mismatch \(got ([^)]*)\)/, 'empreinte du certificat différente (reçue : $1)'],
|
||||||
|
[/host key not recorded yet, test the cluster first/, "clé d'hôte pas encore enregistrée, testez d'abord le cluster"],
|
||||||
|
[/: not found on the target$/, ' : absent de la cible'],
|
||||||
|
[/: field "([^"]*)" required for creation, add it to the mapping of target (.*)$/, ' : champ « $1 » obligatoire pour la création, à ajouter au mapping de la cible $2'],
|
||||||
|
[/: field "([^"]*)" differs \((.*)\) and cannot be changed, re-creation needed$/, ' : champ « $1 » différent ($2) et non modifiable, recréation nécessaire'],
|
||||||
|
[/: (\S+) absent on target (\S+) and not replicated$/, ' : $1 absent de la cible $2 et non répliqué'],
|
||||||
|
[/^zone (\S+): type "([^"]*)" not handled in v1, skipped$/, 'zone $1 : type « $2 » non pris en charge, ignorée'],
|
||||||
|
[/^vnet (\S+): zone "([^"]*)" not handled, skipped$/, 'vnet $1 : zone « $2 » non prise en charge, ignoré'],
|
||||||
|
[/plan has (\d+) error\(s\), nothing applied/, 'le plan a $1 erreur(s), rien appliqué'],
|
||||||
|
[/still different after apply: /, 'toujours différent après réplication : '],
|
||||||
|
[/SDN lock \(pending changes on the target\?\)/, 'verrou SDN (changements en attente sur la cible ?)'],
|
||||||
|
[/SDN configuration committed but reload failed/, 'configuration SDN validée mais rechargement en échec'],
|
||||||
|
[/root@pam cannot be deleted/, 'root@pam ne peut pas être supprimé'],
|
||||||
|
[/\bconnection refused\b/, 'connexion refusée'],
|
||||||
|
];
|
||||||
|
function tm(msg) {
|
||||||
|
if (lang !== 'fr' || !msg) return msg;
|
||||||
|
let out = String(msg);
|
||||||
|
for (const [re, to] of MSG_FR) out = out.replace(re, to);
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
let toastTimer;
|
let toastTimer;
|
||||||
@@ -317,7 +400,7 @@
|
|||||||
}
|
}
|
||||||
const data = await r.json().catch(() => ({}));
|
const data = await r.json().catch(() => ({}));
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
const e = new Error(data.error || r.statusText);
|
const e = new Error(tm(data.error || r.statusText));
|
||||||
e.status = r.status;
|
e.status = r.status;
|
||||||
e.code = data.code;
|
e.code = data.code;
|
||||||
e.data = data;
|
e.data = data;
|
||||||
@@ -497,7 +580,7 @@
|
|||||||
h('div', { class: 'c-head' }, h('span', { class: 'c-role', text: t('reference') }), h('span', { class: 'c-name', text: r.name }),
|
h('div', { class: 'c-head' }, h('span', { class: 'c-role', text: t('reference') }), h('span', { class: 'c-name', text: r.name }),
|
||||||
h('span', { class: 'c-ver', text: r.version ? 'PVE ' + r.version : '' })),
|
h('span', { class: 'c-ver', text: r.version ? 'PVE ' + r.version : '' })),
|
||||||
h('div', { class: 'c-url', text: r.url }),
|
h('div', { class: 'c-url', text: r.url }),
|
||||||
r.error ? h('div', { class: 'c-err', text: r.error, title: r.error })
|
r.error ? h('div', { class: 'c-err', text: tm(r.error), title: r.error })
|
||||||
: h('div', { class: 'c-counts' }, h('span', { class: 'muted small', text: t('objectsN', { n: r.objects }) }),
|
: h('div', { class: 'c-counts' }, h('span', { class: 'muted small', text: t('objectsN', { n: r.objects }) }),
|
||||||
r.nodes && r.nodes.length ? h('span', { class: 'muted small', text: '· ' + t('nodes', { n: r.nodes.length }) }) : null)));
|
r.nodes && r.nodes.length ? h('span', { class: 'muted small', text: '· ' + t('nodes', { n: r.nodes.length }) }) : null)));
|
||||||
for (const c of st.clients) {
|
for (const c of st.clients) {
|
||||||
@@ -520,7 +603,7 @@
|
|||||||
h('div', { class: 'c-head' }, h('span', { class: 'c-name', text: c.name }), h('span', { class: 'c-ver', text: c.version ? 'PVE ' + c.version : '' })),
|
h('div', { class: 'c-head' }, h('span', { class: 'c-name', text: c.name }), h('span', { class: 'c-ver', text: c.version ? 'PVE ' + c.version : '' })),
|
||||||
h('div', { class: 'c-url', text: c.url }),
|
h('div', { class: 'c-url', text: c.url }),
|
||||||
counts,
|
counts,
|
||||||
c.error ? h('div', { class: 'c-err', text: c.error, title: c.error }) : null));
|
c.error ? h('div', { class: 'c-err', text: tm(c.error), title: c.error }) : null));
|
||||||
}
|
}
|
||||||
renderClient();
|
renderClient();
|
||||||
}
|
}
|
||||||
@@ -586,7 +669,7 @@
|
|||||||
$('#objEmptyHint').textContent = hint;
|
$('#objEmptyHint').textContent = hint;
|
||||||
}
|
}
|
||||||
function note(kind, label, text) {
|
function note(kind, label, text) {
|
||||||
return h('div', { class: 'note ' + kind }, h('b', { text: label }), h('span', { text }));
|
return h('div', { class: 'note ' + kind }, h('b', { text: label }), h('span', { text: tm(text) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function match(r) {
|
function match(r) {
|
||||||
@@ -606,7 +689,7 @@
|
|||||||
|
|
||||||
function summary(r) {
|
function summary(r) {
|
||||||
const s = h('span', { class: 'sum' });
|
const s = h('span', { class: 'sum' });
|
||||||
if (r.status === 'error') { s.textContent = (r.errors || [])[0] || ''; s.title = s.textContent; return s; }
|
if (r.status === 'error') { s.textContent = tm((r.errors || [])[0] || ''); s.title = s.textContent; return s; }
|
||||||
const ch = (r.fields || []).filter((f) => f.change);
|
const ch = (r.fields || []).filter((f) => f.change);
|
||||||
if (!ch.length || r.status === 'delete') {
|
if (!ch.length || r.status === 'delete') {
|
||||||
s.textContent = t('fieldsN', { n: (r.fields || []).length });
|
s.textContent = t('fieldsN', { n: (r.fields || []).length });
|
||||||
@@ -648,7 +731,7 @@
|
|||||||
|
|
||||||
function details(r) {
|
function details(r) {
|
||||||
const d = h('div', { class: 'details' });
|
const d = h('div', { class: 'details' });
|
||||||
if ((r.errors || []).length) d.appendChild(h('p', { class: 'errs', text: r.errors.join('\n') }));
|
if ((r.errors || []).length) d.appendChild(h('p', { class: 'errs', text: r.errors.map(tm).join('\n') }));
|
||||||
const tb = h('tbody');
|
const tb = h('tbody');
|
||||||
for (const f of r.fields || []) {
|
for (const f of r.fields || []) {
|
||||||
const cell = (v, cls) => h('td', { class: cls + (v == null ? ' none' : ''), text: v == null ? '—' : fmtVal(v) });
|
const cell = (v, cls) => h('td', { class: cls + (v == null ? ' none' : ''), text: v == null ? '—' : fmtVal(v) });
|
||||||
@@ -699,6 +782,7 @@
|
|||||||
try {
|
try {
|
||||||
const d = await api('POST', 'api/dryrun', sel);
|
const d = await api('POST', 'api/dryrun', sel);
|
||||||
plan.plans = d.plans;
|
plan.plans = d.plans;
|
||||||
|
renderPlanKinds(sel, d.plans, intentSync);
|
||||||
renderPlans(d.plans);
|
renderPlans(d.plans);
|
||||||
$('#planNote').textContent = t('planSaved', { id: d.run });
|
$('#planNote').textContent = t('planSaved', { id: d.run });
|
||||||
const total = d.plans.reduce((n, p) => n + ((p.plan && p.plan.changes) || []).length, 0);
|
const total = d.plans.reduce((n, p) => n + ((p.plan && p.plan.changes) || []).length, 0);
|
||||||
@@ -717,6 +801,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renderPlanKinds shows, above the plan, one "replicate / do not replicate"
|
||||||
|
// choice per replicated item: a change runs the dry-run again without the
|
||||||
|
// items left out, so the reviewed plan is the one applied.
|
||||||
|
function renderPlanKinds(sel, plans, intentSync) {
|
||||||
|
const box = $('#planKinds');
|
||||||
|
const kinds = (S.settings && S.settings.kinds) || KINDS;
|
||||||
|
const items = ITEMS.filter((k) => kinds.includes(k));
|
||||||
|
box.hidden = sel.scope === 'object' || items.length < 2;
|
||||||
|
if (box.hidden) return;
|
||||||
|
const skip = new Set(sel.skip || []);
|
||||||
|
const count = (k) => plans.reduce((n, p) => n + ((p.plan && p.plan.changes) || []).filter((c) => bundled(k).includes(c.kind)).length, 0);
|
||||||
|
const rows = items.map((k) => {
|
||||||
|
const off = skip.has(k);
|
||||||
|
const set = (v) => {
|
||||||
|
const next = new Set(skip);
|
||||||
|
bundled(k).forEach((x) => (v ? next.delete(x) : next.add(x)));
|
||||||
|
openPlan(Object.assign({}, sel, { skip: KINDS.filter((x) => next.has(x)) }), intentSync);
|
||||||
|
};
|
||||||
|
const n = off ? '—' : String(count(k));
|
||||||
|
return h('div', { class: 'pk-row' + (off ? ' off' : '') },
|
||||||
|
h('span', { class: 'kind ' + k, text: t('kindShort.' + k) }),
|
||||||
|
h('span', { class: 'pk-name', text: t('kinds.' + k) }),
|
||||||
|
h('span', { class: 'pk-n', text: n, title: off ? '' : t('changesN', { n }) }),
|
||||||
|
h('div', { class: 'seg', role: 'radiogroup', 'aria-label': t('kinds.' + k) },
|
||||||
|
h('button', { type: 'button', role: 'radio', class: off ? '' : 'on', 'aria-checked': String(!off), onclick: () => off && set(true) }, t('kindOn')),
|
||||||
|
h('button', { type: 'button', role: 'radio', class: off ? 'on' : '', 'aria-checked': String(off), onclick: () => !off && set(false) }, t('kindOff'))));
|
||||||
|
});
|
||||||
|
box.replaceChildren(h('div', { class: 'pk-head' }, h('strong', { text: t('planKinds') }), h('span', { class: 'muted small', text: t('planKindsHelp') })), ...rows);
|
||||||
|
}
|
||||||
|
|
||||||
function changeList(changes) {
|
function changeList(changes) {
|
||||||
const ul = h('ul', { class: 'plan' });
|
const ul = h('ul', { class: 'plan' });
|
||||||
for (const ch of changes || []) {
|
for (const ch of changes || []) {
|
||||||
@@ -786,7 +900,7 @@
|
|||||||
busy(true);
|
busy(true);
|
||||||
try {
|
try {
|
||||||
const d = await api('POST', 'api/apply', Object.assign({}, sel, { digests, confirm: confirmText }));
|
const d = await api('POST', 'api/apply', Object.assign({}, sel, { digests, confirm: confirmText }));
|
||||||
if (!d.run) { toast(d.summary || t('planEmpty')); return; }
|
if (!d.run) { toast(tm(d.summary) || t('planEmpty')); return; }
|
||||||
plan.run = d.run;
|
plan.run = d.run;
|
||||||
$('#planApply').hidden = true;
|
$('#planApply').hidden = true;
|
||||||
$('#planTitle').textContent = t('applying');
|
$('#planTitle').textContent = t('applying');
|
||||||
@@ -819,7 +933,7 @@
|
|||||||
if (run.status !== 'running') {
|
if (run.status !== 'running') {
|
||||||
clearInterval(plan.poll);
|
clearInterval(plan.poll);
|
||||||
$('#planTitle').textContent = t('applyDone') + ' — ' + t('res.' + run.status);
|
$('#planTitle').textContent = t('applyDone') + ' — ' + t('res.' + run.status);
|
||||||
$('#planNote').textContent = t('runN', { id: run.id }) + ' · ' + run.summary;
|
$('#planNote').textContent = t('runN', { id: run.id }) + ' · ' + tm(run.summary);
|
||||||
loadState(true);
|
loadState(true);
|
||||||
if (S.view === 'runs') loadRuns(true);
|
if (S.view === 'runs') loadRuns(true);
|
||||||
}
|
}
|
||||||
@@ -887,7 +1001,7 @@
|
|||||||
h('span', { class: 'txt', text: t('scope.' + r.scope) }),
|
h('span', { class: 'txt', text: t('scope.' + r.scope) }),
|
||||||
h('span', { class: 'txt mono', text: (r.clients || []).join(', '), title: (r.objects || []).join(', ') }),
|
h('span', { class: 'txt mono', text: (r.clients || []).join(', '), title: (r.objects || []).join(', ') }),
|
||||||
h('span', null, h('span', { class: 'badge ' + r.status, text: t('res.' + r.status) })),
|
h('span', null, h('span', { class: 'badge ' + r.status, text: t('res.' + r.status) })),
|
||||||
h('span', { class: 'txt', text: r.summary, title: r.summary }));
|
h('span', { class: 'txt', text: tm(r.summary), title: tm(r.summary) }));
|
||||||
if (open) {
|
if (open) {
|
||||||
const det = h('div', { class: 'details' }, h('p', { class: 'muted', text: '…' }));
|
const det = h('div', { class: 'details' }, h('p', { class: 'muted', text: '…' }));
|
||||||
row.appendChild(det);
|
row.appendChild(det);
|
||||||
@@ -991,7 +1105,7 @@
|
|||||||
const parts = [t('testedAt', { t: fmtTime(k.at, true) })];
|
const parts = [t('testedAt', { t: fmtTime(k.at, true) })];
|
||||||
if (k.version) parts.push('PVE ' + k.version);
|
if (k.version) parts.push('PVE ' + k.version);
|
||||||
if ((k.nodes || []).length) parts.push(t('nodes', { n: k.nodes.length }) + ' (' + k.nodes.join(', ') + ')');
|
if ((k.nodes || []).length) parts.push(t('nodes', { n: k.nodes.length }) + ' (' + k.nodes.join(', ') + ')');
|
||||||
return h('span', { class: k.ok ? '' : 'ko', text: (k.ok ? '✓ ' : '✗ ') + parts.join(' · ') + (k.error ? ' — ' + k.error : '') });
|
return h('span', { class: k.ok ? '' : 'ko', text: (k.ok ? '✓ ' : '✗ ') + parts.join(' · ') + (k.error ? ' — ' + tm(k.error) : '') });
|
||||||
}
|
}
|
||||||
function renderClusters() {
|
function renderClusters() {
|
||||||
const list = $('#clusterList');
|
const list = $('#clusterList');
|
||||||
@@ -1056,7 +1170,7 @@
|
|||||||
if (r.sshFingerprint && !r.sshMismatch) pins.push('SSH ' + r.sshFingerprint);
|
if (r.sshFingerprint && !r.sshMismatch) pins.push('SSH ' + r.sshFingerprint);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
el.className = 'chk ko';
|
el.className = 'chk ko';
|
||||||
el.replaceChildren('✗ ' + r.error);
|
el.replaceChildren('✗ ' + tm(r.error));
|
||||||
if (r.sshMismatch) {
|
if (r.sshMismatch) {
|
||||||
el.append(' ', h('button', { type: 'button', class: 'link-btn', text: t('trustKey'),
|
el.append(' ', h('button', { type: 'button', class: 'link-btn', text: t('trustKey'),
|
||||||
onclick: () => { if (confirm(t('confirmTrust', { c: name, fp: r.sshFingerprint }))) checkCluster(name, el, true); } }));
|
onclick: () => { if (confirm(t('confirmTrust', { c: name, fp: r.sshFingerprint }))) checkCluster(name, el, true); } }));
|
||||||
@@ -1065,7 +1179,7 @@
|
|||||||
}
|
}
|
||||||
el.className = 'chk ' + ((r.warnings || []).length ? 'warn' : 'ok');
|
el.className = 'chk ' + ((r.warnings || []).length ? 'warn' : 'ok');
|
||||||
el.textContent = '✓ ' + t('testOk') + (pins.length ? ' — ' + t('pinned', { l: pins.join(' · ') }) : '') +
|
el.textContent = '✓ ' + t('testOk') + (pins.length ? ' — ' + t('pinned', { l: pins.join(' · ') }) : '') +
|
||||||
((r.warnings || []).length ? ' — ⚠ ' + r.warnings.join(' ; ') : '');
|
((r.warnings || []).length ? ' — ⚠ ' + r.warnings.map(tm).join(' ; ') : '');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
async function testAll() {
|
async function testAll() {
|
||||||
@@ -1306,7 +1420,7 @@
|
|||||||
password: $('#bulkPassword').value, hasPassword: false, tls: $('#bulkTLS').value, fingerprint: '', caPem: '', timeout: '',
|
password: $('#bulkPassword').value, hasPassword: false, tls: $('#bulkTLS').value, fingerprint: '', caPem: '', timeout: '',
|
||||||
sshHost: '', sshPort: 22, sshHostKey: '', sshFingerprint: '', mapping: {}, policies: {}, updatedAt: new Date().toISOString() });
|
sshHost: '', sshPort: 22, sshHostKey: '', sshFingerprint: '', mapping: {}, policies: {}, updatedAt: new Date().toISOString() });
|
||||||
const r = await api('POST', 'api/clusters/' + encodeURIComponent(it.name) + '/check');
|
const r = await api('POST', 'api/clusters/' + encodeURIComponent(it.name) + '/check');
|
||||||
line(r.ok ? 'ok' : 'ko', (r.ok ? '✓ ' : '✗ ') + it.name + ' — ' + (r.ok ? 'PVE ' + r.version + (r.sshFingerprint ? ' · SSH ' + r.sshFingerprint : '') : r.error));
|
line(r.ok ? 'ok' : 'ko', (r.ok ? '✓ ' : '✗ ') + it.name + ' — ' + (r.ok ? 'PVE ' + r.version + (r.sshFingerprint ? ' · SSH ' + r.sshFingerprint : '') : tm(r.error)));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.message === 'auth') return;
|
if (e.message === 'auth') return;
|
||||||
line('ko', '✗ ' + it.name + ' — ' + e.message);
|
line('ko', '✗ ' + it.name + ' — ' + e.message);
|
||||||
@@ -1322,7 +1436,6 @@
|
|||||||
try {
|
try {
|
||||||
const d = await api('GET', 'api/settings');
|
const d = await api('GET', 'api/settings');
|
||||||
set.settings = d.settings;
|
set.settings = d.settings;
|
||||||
set.defaults = d.defaults || {};
|
|
||||||
S.settings = d.settings;
|
S.settings = d.settings;
|
||||||
S.deps = d.deps || {};
|
S.deps = d.deps || {};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1335,7 +1448,7 @@
|
|||||||
function renderSettings() {
|
function renderSettings() {
|
||||||
const st = set.settings;
|
const st = set.settings;
|
||||||
const chips = $('#kindChips');
|
const chips = $('#kindChips');
|
||||||
chips.replaceChildren(...KINDS.map((k) => h('button', {
|
chips.replaceChildren(...ITEMS.map((k) => h('button', {
|
||||||
type: 'button', class: 'chip' + ((st.kinds || []).includes(k) ? ' on' : ''), dataset: { kind: k }, 'aria-pressed': String((st.kinds || []).includes(k)),
|
type: 'button', class: 'chip' + ((st.kinds || []).includes(k) ? ' on' : ''), dataset: { kind: k }, 'aria-pressed': String((st.kinds || []).includes(k)),
|
||||||
onclick: (ev) => {
|
onclick: (ev) => {
|
||||||
const on = withDeps($$('#kindChips .chip.on').map((c) => c.dataset.kind), k, !ev.currentTarget.classList.contains('on'));
|
const on = withDeps($$('#kindChips .chip.on').map((c) => c.dataset.kind), k, !ev.currentTarget.classList.contains('on'));
|
||||||
@@ -1345,14 +1458,14 @@
|
|||||||
}, t('kinds.' + k))));
|
}, t('kinds.' + k))));
|
||||||
$('#pruneSw').checked = !!st.prune;
|
$('#pruneSw').checked = !!st.prune;
|
||||||
const sel = $('#selRows');
|
const sel = $('#selRows');
|
||||||
sel.replaceChildren(...KINDS.map((k) => {
|
sel.replaceChildren(...ITEMS.map((k) => {
|
||||||
const o = (st.objects || {})[k] || {};
|
const o = (st.objects || {})[k] || {};
|
||||||
return h('div', { class: 'erow sel', dataset: { kind: k } },
|
return h('div', { class: 'erow sel', dataset: { kind: k } },
|
||||||
h('span', { class: 'kind ' + k, text: t('kindShort.' + k) }),
|
h('span', { class: 'kind ' + k, text: t('kindShort.' + k) }),
|
||||||
h('input', { class: 'inp mono', value: (o.include || []).join(', '), placeholder: '*', 'data-k': 'include', oninput: () => setDirty(true) }),
|
h('input', { class: 'inp mono', value: (o.include || []).join(', '), placeholder: '*', 'data-k': 'include', oninput: () => setDirty(true) }),
|
||||||
h('input', { class: 'inp mono', value: (o.exclude || []).join(', '), placeholder: 'lab*', 'data-k': 'exclude', oninput: () => setDirty(true) }));
|
h('input', { class: 'inp mono', value: (o.exclude || []).join(', '), placeholder: 'lab*', 'data-k': 'exclude', oninput: () => setDirty(true) }));
|
||||||
}));
|
}));
|
||||||
$('#polDefaults').replaceChildren(...KINDS.map((k) => h('li', null, h('strong', { text: t('kinds.' + k) + ' : ' }), set.defaults[k] || '')));
|
$('#polDefaults').replaceChildren(...ITEMS.map((k) => h('li', null, h('strong', { text: t('kinds.' + k) + ' : ' }), t('polDef.' + k))));
|
||||||
const pr = $('#polRows');
|
const pr = $('#polRows');
|
||||||
pr.replaceChildren();
|
pr.replaceChildren();
|
||||||
for (const [k, fields] of Object.entries(st.policies || {})) {
|
for (const [k, fields] of Object.entries(st.policies || {})) {
|
||||||
@@ -1374,8 +1487,10 @@
|
|||||||
const exc = splitP($('[data-k="exclude"]', row).value);
|
const exc = splitP($('[data-k="exclude"]', row).value);
|
||||||
if (inc.length || exc.length) objects[row.dataset.kind] = { include: inc, exclude: exc };
|
if (inc.length || exc.length) objects[row.dataset.kind] = { include: inc, exclude: exc };
|
||||||
}
|
}
|
||||||
|
// a password follows the selection of its account
|
||||||
|
for (const [k, b] of Object.entries(BUNDLES)) for (const x of b) { if (objects[k]) objects[x] = objects[k]; }
|
||||||
const body = {
|
const body = {
|
||||||
kinds: $$('#kindChips .chip.on').map((c) => c.dataset.kind),
|
kinds: KINDS.filter((k) => $$('#kindChips .chip.on').some((c) => bundled(c.dataset.kind).includes(k))),
|
||||||
objects,
|
objects,
|
||||||
policies: readPolicies($('#polRows')),
|
policies: readPolicies($('#polRows')),
|
||||||
prune: $('#pruneSw').checked,
|
prune: $('#pruneSw').checked,
|
||||||
@@ -1395,15 +1510,15 @@
|
|||||||
// withDeps checks or unchecks a kind with the kinds it needs (or that need it).
|
// withDeps checks or unchecks a kind with the kinds it needs (or that need it).
|
||||||
function withDeps(kinds, k, on) {
|
function withDeps(kinds, k, on) {
|
||||||
const out = new Set(kinds);
|
const out = new Set(kinds);
|
||||||
const need = (x) => { out.add(x); (S.deps[x] || []).forEach(need); };
|
const need = (x) => { out.add(x); bundled(x).forEach((b) => out.add(b)); (S.deps[x] || []).forEach(need); };
|
||||||
const drop = (x) => { out.delete(x); Object.entries(S.deps).forEach(([y, ds]) => { if (ds.includes(x) && out.has(y)) drop(y); }); };
|
const drop = (x) => { out.delete(x); Object.entries(S.deps).forEach(([y, ds]) => { if (ds.includes(x) && out.has(y)) drop(y); }); };
|
||||||
if (on) need(k); else drop(k);
|
if (on) need(k); else bundled(k).forEach(drop);
|
||||||
return KINDS.filter((x) => out.has(x));
|
return KINDS.filter((x) => out.has(x));
|
||||||
}
|
}
|
||||||
function renderKindMenu() {
|
function renderKindMenu() {
|
||||||
const pop = $('#kindMenuPop');
|
const pop = $('#kindMenuPop');
|
||||||
const kinds = (S.settings && S.settings.kinds) || KINDS;
|
const kinds = (S.settings && S.settings.kinds) || KINDS;
|
||||||
$('#kindMenuN').textContent = kinds.length + '/' + KINDS.length;
|
$('#kindMenuN').textContent = ITEMS.filter((k) => kinds.includes(k)).length + '/' + ITEMS.length;
|
||||||
const admin = can.admin();
|
const admin = can.admin();
|
||||||
pop.replaceChildren();
|
pop.replaceChildren();
|
||||||
for (const [g, ks] of KIND_GROUPS) {
|
for (const [g, ks] of KIND_GROUPS) {
|
||||||
|
|||||||
+4
-3
@@ -96,7 +96,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button id="syncAllBtn" class="btn tool sync" type="button">
|
<button id="syncAllBtn" class="btn tool sync" type="button">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12a9 9 0 0 1 15.7-6L21 8M21 3v5h-5M21 12a9 9 0 0 1-15.7 6L3 16M3 21v-5h5"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12a9 9 0 0 1 15.7-6L21 8M21 3v5h-5M21 12a9 9 0 0 1-15.7 6L3 16M3 21v-5h5"/></svg>
|
||||||
<span data-i18n="syncAll">Tout synchroniser</span>
|
<span data-i18n="syncAll">Répliquer</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -191,6 +191,7 @@
|
|||||||
<h2 id="planTitle"></h2>
|
<h2 id="planTitle"></h2>
|
||||||
<button class="icon-btn" type="button" data-close data-i18n-aria="close"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
|
<button class="icon-btn" type="button" data-close data-i18n-aria="close"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
|
||||||
</header>
|
</header>
|
||||||
|
<div id="planKinds" class="plan-kinds" hidden></div>
|
||||||
<div id="planBody" class="dlg-body"></div>
|
<div id="planBody" class="dlg-body"></div>
|
||||||
<footer>
|
<footer>
|
||||||
<span id="planNote" class="muted small"></span>
|
<span id="planNote" class="muted small"></span>
|
||||||
@@ -198,7 +199,7 @@
|
|||||||
<button id="planClose" class="btn ghost" type="button" data-close data-i18n="close">Fermer</button>
|
<button id="planClose" class="btn ghost" type="button" data-close data-i18n="close">Fermer</button>
|
||||||
<button id="planApply" class="btn primary" type="button" hidden>
|
<button id="planApply" class="btn primary" type="button" hidden>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12a9 9 0 0 1 15.7-6L21 8M21 3v5h-5M21 12a9 9 0 0 1-15.7 6L3 16M3 21v-5h5"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12a9 9 0 0 1 15.7-6L21 8M21 3v5h-5M21 12a9 9 0 0 1-15.7 6L3 16M3 21v-5h5"/></svg>
|
||||||
<span data-i18n="applyBtn">Synchroniser</span>
|
<span data-i18n="applyBtn">Répliquer</span>
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
@@ -315,7 +316,7 @@
|
|||||||
<nav id="setNav" class="set-nav" role="tablist" aria-orientation="vertical">
|
<nav id="setNav" class="set-nav" role="tablist" aria-orientation="vertical">
|
||||||
<button type="button" role="tab" data-tab="sync">
|
<button type="button" role="tab" data-tab="sync">
|
||||||
<svg viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 15.7-6L21 8M21 3v5h-5M21 12a9 9 0 0 1-15.7 6L3 16M3 21v-5h5"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 15.7-6L21 8M21 3v5h-5M21 12a9 9 0 0 1-15.7 6L3 16M3 21v-5h5"/></svg>
|
||||||
<span data-i18n="tabSync">Synchronisation</span>
|
<span data-i18n="tabSync">Réplication</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" role="tab" data-tab="users" data-admin>
|
<button type="button" role="tab" data-tab="users" data-admin>
|
||||||
<!-- Lucide "users" (ISC license) -->
|
<!-- Lucide "users" (ISC license) -->
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@
|
|||||||
<svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8"/><path d="M8.5 14.5a8 8 0 0 1 13.6-4.6M22.6 6v4.2h-4.2M23.5 17.5a8 8 0 0 1-13.6 4.6M9.4 26v-4.2h4.2"/></svg>
|
<svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8"/><path d="M8.5 14.5a8 8 0 0 1 13.6-4.6M22.6 6v4.2h-4.2M23.5 17.5a8 8 0 0 1-13.6 4.6M9.4 26v-4.2h4.2"/></svg>
|
||||||
<span>pve-confsync</span>
|
<span>pve-confsync</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted" data-i18n="intro">Synchronisation de configuration Proxmox VE</p>
|
<p class="muted" data-i18n="intro">Réplication de configuration Proxmox VE</p>
|
||||||
|
|
||||||
<form id="setupForm" class="setup-form" method="post" action="auth/setup" hidden>
|
<form id="setupForm" class="setup-form" method="post" action="auth/setup" hidden>
|
||||||
<h2 data-i18n="setupTitle">Premier démarrage</h2>
|
<h2 data-i18n="setupTitle">Premier démarrage</h2>
|
||||||
|
|||||||
+2
-2
@@ -1,14 +1,14 @@
|
|||||||
// Login page: language, theme, local form and/or OIDC button (from /auth/config).
|
// Login page: language, theme, local form and/or OIDC button (from /auth/config).
|
||||||
(function () {
|
(function () {
|
||||||
var I18N = {
|
var I18N = {
|
||||||
fr: { intro: 'Synchronisation de configuration Proxmox VE', user: 'Utilisateur', pass: 'Mot de passe',
|
fr: { intro: 'Réplication de configuration Proxmox VE', user: 'Utilisateur', pass: 'Mot de passe',
|
||||||
submit: 'Se connecter', sso: 'Se connecter avec le SSO', or: 'ou',
|
submit: 'Se connecter', sso: 'Se connecter avec le SSO', or: 'ou',
|
||||||
error: 'Utilisateur ou mot de passe incorrect.', theme: 'Thème clair / sombre',
|
error: 'Utilisateur ou mot de passe incorrect.', theme: 'Thème clair / sombre',
|
||||||
demo: 'Mode démo : comptes {a}, {o} et {l}, mot de passe {p}.',
|
demo: 'Mode démo : comptes {a}, {o} et {l}, mot de passe {p}.',
|
||||||
setupTitle: 'Premier démarrage', setupIntro: 'La base est vide : créez le compte administrateur de pve-confsync (12 caractères minimum).',
|
setupTitle: 'Premier démarrage', setupIntro: 'La base est vide : créez le compte administrateur de pve-confsync (12 caractères minimum).',
|
||||||
pass2: 'Confirmation', setupBtn: 'Créer le compte et continuer',
|
pass2: 'Confirmation', setupBtn: 'Créer le compte et continuer',
|
||||||
mismatch: 'Les deux mots de passe diffèrent.', invalid: 'Nom ou mot de passe refusé (12 caractères minimum).' },
|
mismatch: 'Les deux mots de passe diffèrent.', invalid: 'Nom ou mot de passe refusé (12 caractères minimum).' },
|
||||||
en: { intro: 'Proxmox VE configuration synchronization', user: 'User', pass: 'Password',
|
en: { intro: 'Proxmox VE configuration replication', user: 'User', pass: 'Password',
|
||||||
submit: 'Sign in', sso: 'Sign in with SSO', or: 'or',
|
submit: 'Sign in', sso: 'Sign in with SSO', or: 'or',
|
||||||
error: 'Wrong user or password.', theme: 'Light / dark theme',
|
error: 'Wrong user or password.', theme: 'Light / dark theme',
|
||||||
demo: 'Demo mode: accounts {a}, {o} and {l}, password {p}.',
|
demo: 'Demo mode: accounts {a}, {o} and {l}, password {p}.',
|
||||||
|
|||||||
@@ -741,3 +741,12 @@ body.login-page { min-height: 100vh; padding: 16px; display: grid; place-items:
|
|||||||
.setup-form { width: 100%; display: flex; flex-direction: column; gap: 6px; }
|
.setup-form { width: 100%; display: flex; flex-direction: column; gap: 6px; }
|
||||||
.setup-form h2 { margin: 0 0 4px; font-size: 15px; text-align: center; }
|
.setup-form h2 { margin: 0 0 4px; font-size: 15px; text-align: center; }
|
||||||
.setup-form p { margin: 0 0 6px; font-size: 12.5px; color: var(--muted); }
|
.setup-form p { margin: 0 0 6px; font-size: 12.5px; color: var(--muted); }
|
||||||
|
|
||||||
|
/* dry-run: replicate / do not replicate, per item */
|
||||||
|
.plan-kinds { display: grid; gap: 6px; padding: 12px 20px; border-bottom: 1px solid var(--border); background: var(--panel-2); }
|
||||||
|
.plan-kinds .pk-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; margin-bottom: 2px; }
|
||||||
|
.plan-kinds .pk-row { display: grid; grid-template-columns: 6.5rem minmax(0, 1fr) 2.5rem auto; align-items: center; gap: 10px; }
|
||||||
|
.plan-kinds .pk-row.off .pk-name, .plan-kinds .pk-row.off .kind { opacity: .55; }
|
||||||
|
.plan-kinds .pk-n { text-align: right; font-variant-numeric: tabular-nums; color: var(--muted); }
|
||||||
|
.plan-kinds .seg button { padding: 3px 10px; font-size: 12px; }
|
||||||
|
@media (max-width: 560px) { .plan-kinds .pk-row { grid-template-columns: minmax(0, 1fr) auto; } .plan-kinds .pk-row .kind, .plan-kinds .pk-n { display: none; } }
|
||||||
Reference in new issue
Block a user