feat(manager): version 1.4.2 — H2 sk- hash + M9 audit drawer + M3 docs + M7 vault
Bundled release bumping Manager to 1.4.2 with four product-doc gap
closures lined up in a single deploy.
VERSION:
- 1.2.0 → 1.4.2 (catches up after Sprints 1-5 shipped under 1.2.0)
H2 — sk- hash phase A (server-side, zero client impact):
- tokens table: new key_hash varchar(64) index column
- Token.Insert() dual-writes Key + KeyHash on every new token
- BackfillTokenKeyHash() runs at startup, batches 500 rows at a
time, idempotent. Fills legacy rows that pre-date the column
without blocking app boot
- 5 unit tests pin: sha256 correctness, dual-write on Insert,
empty Key → empty hash, backfill behaviour, idempotency
- Phase B (switch lookup index off plaintext + drop Key column)
can ship later once telemetry shows key_hash IS NULL count is 0
M9 — task detail drawer with audit timeline:
- Deployments page click → Sheet drawer with RunDetailPanel +
new RunAuditTimeline component
- Timeline pulls from existing /api/agnet/deployments/:id/events
which Sprint 1 already wired to the persistent
agnet_audit_events table — no new backend
- Vertical timeline w/ coloured dots (primary / amber / rose by
classifyEventLevel), occurred_at + correlation_id per row,
max-height + overflow for long traces
- 15s polling; empty/loading/error states all rendered
M3 — project_doc as a first-class binding step:
- Resource binding wizard split "SK or project docs" into two
distinct steps: "Connect project docs" + "Connect SK skill packs"
- Each step's Connect button pre-selects the matching type in
the advanced sheet so users don't accidentally tag a doc repo
as Git or SK
- Summary dialog still receives the combined skOrDocSources view
to keep the recommendation-card contract unchanged
M7 — secret vault status (admin panel):
- controller/secret_store.go: new GetSecretStoreStatus handler
+ fetchHealth() method. Hits OpenBao /sys/health (token-less
upstream endpoint), maps to a sanitized response — NEVER
returns secret names or values per product docs §13.9
- Graceful degradation: env vars unset → "not configured" pill;
network error → "unreachable"; sealed → amber warning; healthy
→ green
- Mounted at GET /api/secret-store/status behind middleware.AdminAuth
- New SecretStoreSection in system-settings/maintenance,
registered before Performance. Read-only card with refresh
button, 7 status fields, message line, "how to enable" hint
Verification:
- go vet ./... clean
- go test ./controller/... ./middleware/... ./model/... all green
- tsc --noEmit clean
- Backend M7 endpoint deliberately tolerant — production may not
have OPENBAO_ADDR set yet, UI shows "not configured" instead of
500ing
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
1.2.0
|
||||
1.4.2
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
)
|
||||
|
||||
@@ -102,3 +104,107 @@ func readSecretStoreError(body io.Reader) string {
|
||||
}
|
||||
return strings.Join(payload.Errors, "; ")
|
||||
}
|
||||
|
||||
// secretStoreHealth is what `GET /v1/sys/health` returns on the
|
||||
// OpenBao/Vault side. We only pull the public-safe fields — version
|
||||
// string, initialized/sealed/standby flags, server time. No cluster
|
||||
// IDs, no auth lease ttls, nothing that could leak operator detail.
|
||||
type secretStoreHealth struct {
|
||||
Initialized bool `json:"initialized"`
|
||||
Sealed bool `json:"sealed"`
|
||||
Standby bool `json:"standby"`
|
||||
Version string `json:"version"`
|
||||
ServerTime int64 `json:"server_time_utc"`
|
||||
}
|
||||
|
||||
// fetchHealth pings the OpenBao /sys/health endpoint. That endpoint
|
||||
// is intentionally token-less in upstream Vault — it's the standard
|
||||
// liveness probe — so we don't include X-Vault-Token here. Vault
|
||||
// returns non-2xx HTTP codes for sealed/uninitialized states by
|
||||
// design (200=initialized+unsealed, 429=standby, 472=DR secondary,
|
||||
// 473=performance standby, 501=not initialized, 503=sealed). All of
|
||||
// those still ship a JSON body with the same fields, so we always
|
||||
// decode and let the caller interpret the status field separately.
|
||||
func (s secretStoreClient) fetchHealth() (secretStoreHealth, int, error) {
|
||||
url := fmt.Sprintf("%s/v1/sys/health", s.address)
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return secretStoreHealth{}, 0, err
|
||||
}
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return secretStoreHealth{}, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var h secretStoreHealth
|
||||
if err := common.DecodeJson(resp.Body, &h); err != nil {
|
||||
return secretStoreHealth{}, resp.StatusCode, err
|
||||
}
|
||||
return h, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// secretStoreStatusResponse is the public-safe envelope rendered by
|
||||
// the admin status page. Crucially does NOT include the configured
|
||||
// address (could leak internal network topology), the token, the
|
||||
// mount path, or any secret names.
|
||||
type secretStoreStatusResponse struct {
|
||||
Configured bool `json:"configured"` // env vars present?
|
||||
Reachable bool `json:"reachable"` // /sys/health responded?
|
||||
Initialized bool `json:"initialized,omitempty"`
|
||||
Sealed bool `json:"sealed,omitempty"`
|
||||
Standby bool `json:"standby,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Message string `json:"message,omitempty"` // human-readable status
|
||||
CheckedAt int64 `json:"checked_at"` // unix ms (server clock)
|
||||
}
|
||||
|
||||
// GetSecretStoreStatus returns a sanitized vault health snapshot for
|
||||
// the admin "密钥保管器" status panel. Admin-only (route is mounted
|
||||
// under the rootRoute group in api-router.go so non-admins can't even
|
||||
// reach it). The handler is deliberately tolerant: missing env vars,
|
||||
// network errors, and sealed-vault responses ALL render usable JSON
|
||||
// the UI can present — we don't bubble HTTP 500 for any of them, the
|
||||
// "missing config" / "network error" / "sealed" states are valid
|
||||
// operating modes the UI must visualise distinctly.
|
||||
func GetSecretStoreStatus(c *gin.Context) {
|
||||
out := secretStoreStatusResponse{
|
||||
CheckedAt: time.Now().UnixMilli(),
|
||||
}
|
||||
client, err := newSecretStoreClientFromEnv()
|
||||
if err != nil {
|
||||
// Most common case in current production: OPENBAO_TOKEN not
|
||||
// set. Render as "not configured" instead of a server error.
|
||||
out.Configured = false
|
||||
out.Message = "secret store env vars not set (OPENBAO_ADDR / OPENBAO_TOKEN). The vault container may still be running but Manager is not wired up to it yet."
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
|
||||
return
|
||||
}
|
||||
out.Configured = true
|
||||
|
||||
health, status, err := client.fetchHealth()
|
||||
if err != nil {
|
||||
out.Reachable = false
|
||||
out.Message = "network error contacting secret store: " + err.Error()
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
|
||||
return
|
||||
}
|
||||
out.Reachable = true
|
||||
out.Initialized = health.Initialized
|
||||
out.Sealed = health.Sealed
|
||||
out.Standby = health.Standby
|
||||
out.Version = health.Version
|
||||
// Vault uses non-2xx HTTP for sealed/uninit states — surface a
|
||||
// short reason so the UI can show a coloured pill without parsing
|
||||
// the boolean matrix itself.
|
||||
switch {
|
||||
case !health.Initialized:
|
||||
out.Message = fmt.Sprintf("secret store reachable but not initialized (HTTP %d)", status)
|
||||
case health.Sealed:
|
||||
out.Message = fmt.Sprintf("secret store reachable but SEALED — operator must unseal (HTTP %d)", status)
|
||||
case health.Standby:
|
||||
out.Message = fmt.Sprintf("secret store in standby mode (HTTP %d)", status)
|
||||
default:
|
||||
out.Message = "secret store is healthy"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
|
||||
}
|
||||
|
||||
@@ -303,6 +303,14 @@ func migrateDB() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// H2 phase A: best-effort backfill of tokens.key_hash for legacy
|
||||
// rows that pre-date the column. Idempotent; safe to run on every
|
||||
// startup. Log + continue on error — auth still works via
|
||||
// plaintext Key lookup during phase A, the backfill is only for
|
||||
// future phase B.
|
||||
if err := BackfillTokenKeyHash(); err != nil {
|
||||
common.SysLog("BackfillTokenKeyHash on startup: " + err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+85
-3
@@ -1,6 +1,8 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -16,6 +18,21 @@ type Token struct {
|
||||
Id int `json:"id"`
|
||||
UserId int `json:"user_id" gorm:"index"`
|
||||
Key string `json:"key" gorm:"type:varchar(128);uniqueIndex"`
|
||||
// KeyHash is the SHA-256 hex digest of the raw `Key` value. H2
|
||||
// phase A: dual-write — every Insert and key-update path writes
|
||||
// both Key and KeyHash. ValidateUserToken still looks up by Key
|
||||
// for now; once telemetry shows zero-NULL coverage we can flip
|
||||
// the lookup to KeyHash, drop the Key uniqueIndex, and stop
|
||||
// storing plaintext bearers in the DB. This is the upgrade path
|
||||
// the V2 device-binding work needs to complete the "no long-lived
|
||||
// plaintext credential at rest" promise from the product docs §13.9.
|
||||
//
|
||||
// Indexed for forward compatibility — phase B switches lookups to
|
||||
// this column. Nullable / empty during the rollout window:
|
||||
// - new rows: filled by Token.Insert / UpdateKey paths
|
||||
// - legacy rows: filled by BackfillTokenKeyHash on startup
|
||||
// SHA-256 hex == 64 chars; varchar(64) is the natural size.
|
||||
KeyHash string `json:"-" gorm:"type:varchar(64);index;column:key_hash;default:''"`
|
||||
Status int `json:"status" gorm:"default:1"`
|
||||
Name string `json:"name" gorm:"index" `
|
||||
CreatedTime int64 `json:"created_time" gorm:"bigint"`
|
||||
@@ -474,10 +491,75 @@ func GetTokenByKey(key string, fromDB bool) (token *Token, err error) {
|
||||
return token, err
|
||||
}
|
||||
|
||||
// BackfillTokenKeyHash fills tokens.key_hash for any rows that still
|
||||
// have an empty hash but a non-empty plaintext Key. Idempotent — safe
|
||||
// to call on every container startup. Batches rows so a large legacy
|
||||
// `tokens` table (10k+ rows) doesn't blow up memory.
|
||||
//
|
||||
// This is the H2 phase-A backfill. Once telemetry shows
|
||||
// COUNT(*) WHERE key_hash = '' AND key != '' equals zero across all
|
||||
// environments, phase B switches ValidateUserToken to look up by hash
|
||||
// and we can drop the Key column.
|
||||
func BackfillTokenKeyHash() error {
|
||||
if DB == nil {
|
||||
return nil
|
||||
}
|
||||
const batchSize = 500
|
||||
totalUpdated := 0
|
||||
for {
|
||||
var rows []Token
|
||||
// Select only id + key, skip deleted-at rows, only the ones
|
||||
// that still need a hash. The model's regular Find honours
|
||||
// soft-delete; that's correct — we don't backfill tombstones.
|
||||
err := DB.Select("id", "key").
|
||||
Where("(key_hash IS NULL OR key_hash = '') AND key IS NOT NULL AND key <> ''").
|
||||
Limit(batchSize).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
break
|
||||
}
|
||||
for _, r := range rows {
|
||||
hash := computeKeyHash(r.Key)
|
||||
if hash == "" {
|
||||
continue
|
||||
}
|
||||
if err := DB.Model(&Token{}).Where("id = ?", r.Id).
|
||||
Update("key_hash", hash).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
totalUpdated++
|
||||
}
|
||||
if len(rows) < batchSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
if totalUpdated > 0 {
|
||||
common.SysLog(fmt.Sprintf("BackfillTokenKeyHash: filled %d rows", totalUpdated))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// computeKeyHash returns the SHA-256 hex digest of `key`. H2 phase A —
|
||||
// stored in tokens.key_hash alongside the plaintext Key so we can move
|
||||
// the auth lookup off plaintext in phase B without a downtime
|
||||
// migration. Empty key returns empty string (zero-value, intentional).
|
||||
func computeKeyHash(key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte(key))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (token *Token) Insert() error {
|
||||
var err error
|
||||
err = DB.Create(token).Error
|
||||
return err
|
||||
// H2: always populate KeyHash on write so phase B can flip the
|
||||
// lookup index without a one-off backfill round-trip. Safe to
|
||||
// re-set on every Insert — value is deterministic from Key.
|
||||
token.KeyHash = computeKeyHash(token.Key)
|
||||
return DB.Create(token).Error
|
||||
}
|
||||
|
||||
// Update Make sure your token's fields is completed, because this will update non-zero values
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// H2 phase A — tests pin the dual-write behaviour:
|
||||
// 1. Insert() populates KeyHash from Key automatically.
|
||||
// 2. computeKeyHash matches stdlib sha256 hex.
|
||||
// 3. BackfillTokenKeyHash fills legacy rows in batches and is
|
||||
// idempotent (running it twice doesn't change anything).
|
||||
// 4. Empty Key produces empty hash (no spurious SHA of "").
|
||||
//
|
||||
// All tests share the package-level DB set up in task_cas_test.go's
|
||||
// TestMain. We DELETE FROM tokens at the top of each case so the
|
||||
// shared SQLite stays isolated.
|
||||
|
||||
func resetTokensTable(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := DB.AutoMigrate(&Token{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if err := DB.Exec("DELETE FROM tokens").Error; err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeKeyHash_MatchesStdlibSHA256(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"", ""},
|
||||
{"a", func() string {
|
||||
s := sha256.Sum256([]byte("a"))
|
||||
return hex.EncodeToString(s[:])
|
||||
}()},
|
||||
{"sk-deadbeef1234567890abcdef", func() string {
|
||||
s := sha256.Sum256([]byte("sk-deadbeef1234567890abcdef"))
|
||||
return hex.EncodeToString(s[:])
|
||||
}()},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := computeKeyHash(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("computeKeyHash(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenInsert_PopulatesKeyHash(t *testing.T) {
|
||||
resetTokensTable(t)
|
||||
|
||||
tok := &Token{
|
||||
UserId: 7,
|
||||
Name: "h2-test",
|
||||
Key: "sk-h2-insert-deadbeef0001",
|
||||
}
|
||||
if err := tok.Insert(); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
if tok.KeyHash == "" {
|
||||
t.Fatal("KeyHash should be set after Insert()")
|
||||
}
|
||||
want := computeKeyHash(tok.Key)
|
||||
if tok.KeyHash != want {
|
||||
t.Errorf("KeyHash = %q, want %q", tok.KeyHash, want)
|
||||
}
|
||||
|
||||
// Confirm DB row carries the hash.
|
||||
var stored Token
|
||||
if err := DB.First(&stored, tok.Id).Error; err != nil {
|
||||
t.Fatalf("lookup: %v", err)
|
||||
}
|
||||
if stored.KeyHash != want {
|
||||
t.Errorf("DB row KeyHash = %q, want %q", stored.KeyHash, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenInsert_EmptyKey_YieldsEmptyHash(t *testing.T) {
|
||||
// Defensive: rows that legitimately have no key (e.g. test fixtures)
|
||||
// should not get a SHA-256 of empty string in the hash column.
|
||||
resetTokensTable(t)
|
||||
|
||||
tok := &Token{
|
||||
UserId: 8,
|
||||
Name: "h2-no-key",
|
||||
Key: "",
|
||||
}
|
||||
// SQLite uniqueIndex on Key blocks two empty-key rows; that's
|
||||
// orthogonal to this test which only asserts hash behaviour
|
||||
// against the row we did insert.
|
||||
if err := tok.Insert(); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
if tok.KeyHash != "" {
|
||||
t.Errorf("empty Key should yield empty KeyHash, got %q", tok.KeyHash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillTokenKeyHash_FillsLegacyRows(t *testing.T) {
|
||||
resetTokensTable(t)
|
||||
|
||||
// Seed: 3 rows directly via DB.Create (bypasses Insert hook) to
|
||||
// simulate legacy rows from before the column existed.
|
||||
for i, k := range []string{"sk-leg-001", "sk-leg-002", "sk-leg-003"} {
|
||||
row := Token{UserId: i + 1, Name: "legacy", Key: k, KeyHash: ""}
|
||||
if err := DB.Create(&row).Error; err != nil {
|
||||
t.Fatalf("seed %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// One row with empty Key — backfill must leave it alone.
|
||||
if err := DB.Create(&Token{UserId: 99, Name: "no-key", Key: "", KeyHash: ""}).Error; err != nil {
|
||||
t.Fatalf("seed empty: %v", err)
|
||||
}
|
||||
|
||||
if err := BackfillTokenKeyHash(); err != nil {
|
||||
t.Fatalf("backfill: %v", err)
|
||||
}
|
||||
|
||||
// All three keyed rows now have a hash.
|
||||
var keyed []Token
|
||||
if err := DB.Where("key <> ''").Find(&keyed).Error; err != nil {
|
||||
t.Fatalf("query: %v", err)
|
||||
}
|
||||
for _, r := range keyed {
|
||||
want := computeKeyHash(r.Key)
|
||||
if r.KeyHash != want {
|
||||
t.Errorf("row id=%d key=%q has KeyHash=%q, want %q",
|
||||
r.Id, r.Key, r.KeyHash, want)
|
||||
}
|
||||
}
|
||||
// The empty-key row stays empty.
|
||||
var emptyKeyRow Token
|
||||
if err := DB.Where("key = ''").First(&emptyKeyRow).Error; err != nil {
|
||||
t.Fatalf("empty row: %v", err)
|
||||
}
|
||||
if emptyKeyRow.KeyHash != "" {
|
||||
t.Errorf("empty-key row should keep empty hash, got %q", emptyKeyRow.KeyHash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillTokenKeyHash_Idempotent(t *testing.T) {
|
||||
// Production safety: backfill runs on every container start.
|
||||
// Running it on an already-backfilled table must be a no-op,
|
||||
// not a double-write that thrashes Redis cache invalidation.
|
||||
resetTokensTable(t)
|
||||
|
||||
tok := &Token{UserId: 1, Name: "ok", Key: "sk-stable-001"}
|
||||
if err := tok.Insert(); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
firstHash := tok.KeyHash
|
||||
|
||||
// First backfill — should already see hash filled by Insert, do nothing.
|
||||
if err := BackfillTokenKeyHash(); err != nil {
|
||||
t.Fatalf("backfill 1: %v", err)
|
||||
}
|
||||
// Second backfill — same.
|
||||
if err := BackfillTokenKeyHash(); err != nil {
|
||||
t.Fatalf("backfill 2: %v", err)
|
||||
}
|
||||
|
||||
var after Token
|
||||
if err := DB.First(&after, tok.Id).Error; err != nil {
|
||||
t.Fatalf("lookup: %v", err)
|
||||
}
|
||||
if after.KeyHash != firstHash {
|
||||
t.Errorf("hash drifted across backfills: was %q, now %q",
|
||||
firstHash, after.KeyHash)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,10 @@ func SetApiRouter(router *gin.Engine) {
|
||||
apiRouter.GET("/uptime/status", controller.GetUptimeKumaStatus)
|
||||
apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels)
|
||||
apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus)
|
||||
// M7 — secret-store (OpenBao / Vault) status snapshot for the
|
||||
// admin "密钥保管器" panel. Admin-only because the response
|
||||
// reveals seal state + version which we don't want public.
|
||||
apiRouter.GET("/secret-store/status", middleware.AdminAuth(), controller.GetSecretStoreStatus)
|
||||
apiRouter.GET("/notice", controller.GetNotice)
|
||||
apiRouter.GET("/user-agreement", controller.GetUserAgreement)
|
||||
apiRouter.GET("/privacy-policy", controller.GetPrivacyPolicy)
|
||||
|
||||
+185
-10
@@ -62,6 +62,13 @@ import {
|
||||
type ResourceType,
|
||||
} from '@/lib/heicode-mcp'
|
||||
import { CreateAgnetDeploymentSheet } from './create-agnet-deployment-sheet'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
type StatusKey = 'running' | 'success' | 'failed' | 'pending'
|
||||
@@ -679,10 +686,141 @@ export function AgnetDeploymentsPage() {
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
/>
|
||||
{/* M9 — task detail drawer. Click a deployment card → audit
|
||||
timeline + permission manifest fold render here. selectedRun
|
||||
is derived from selectedRunId; the drawer mirrors that
|
||||
source-of-truth and closes by clearing the id. */}
|
||||
<Sheet
|
||||
open={Boolean(selectedRunId) && Boolean(selectedRun)}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setSelectedRunId(undefined)
|
||||
}}
|
||||
>
|
||||
<SheetContent className='w-[min(720px,96vw)] sm:max-w-none overflow-y-auto'>
|
||||
{selectedRun && (
|
||||
<>
|
||||
<SheetHeader>
|
||||
<SheetTitle className='text-base'>
|
||||
{selectedRun.orchestration_plan?.objective ||
|
||||
selectedRun.orchestration_plan?.template_hint ||
|
||||
selectedRun.deployment_id}
|
||||
</SheetTitle>
|
||||
<SheetDescription className='font-mono text-[11px]'>
|
||||
{selectedRun.deployment_id}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className='mt-4 space-y-4'>
|
||||
<RunDetailPanel dep={selectedRun} />
|
||||
<RunAuditTimeline deploymentId={selectedRun.deployment_id} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// RunAuditTimeline — M9. Renders the full audit-event stream for a
|
||||
// single deployment, oldest-first (chronological). Data is the
|
||||
// persistent audit table from Sprint 1; an empty list means either
|
||||
// the deployment is too fresh to have generated audit rows yet, or
|
||||
// the server lost connectivity to PostgreSQL (we log + swallow).
|
||||
//
|
||||
// Visual: vertical timeline with a coloured dot per event, the event
|
||||
// name on top, then occurred_at + correlation_id below in muted text.
|
||||
// "Error" and "rejected" events get a red dot; everything else uses
|
||||
// the primary tone. Keeps the drawer skim-friendly under heavy
|
||||
// timelines (60+ events) by setting max-height + overflow.
|
||||
function RunAuditTimeline({ deploymentId }: { deploymentId: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { data = [], isLoading } = useQuery({
|
||||
queryKey: ['agnet', 'deployment-events', deploymentId],
|
||||
queryFn: () => getAgnetDeploymentEvents(deploymentId),
|
||||
enabled: Boolean(deploymentId),
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_52%,transparent)] p-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<p className='text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase'>
|
||||
{t('Audit timeline')}
|
||||
</p>
|
||||
<p className='mt-1 text-xs text-muted-foreground'>
|
||||
{t(
|
||||
'Every observable transition for this deployment. Survives container restarts (stored in DB).'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<span className='inline-flex items-center rounded-full border border-[color-mix(in_oklch,var(--primary)_25%,var(--border))] bg-[color-mix(in_oklch,var(--primary)_10%,transparent)] px-2 py-0.5 text-[10px] font-mono text-primary'>
|
||||
{data.length} {t('events')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className='mt-4 space-y-2'>
|
||||
<Skeleton className='h-6 w-full' />
|
||||
<Skeleton className='h-6 w-5/6' />
|
||||
<Skeleton className='h-6 w-2/3' />
|
||||
</div>
|
||||
) : data.length === 0 ? (
|
||||
<p className='mt-4 text-xs text-muted-foreground italic'>
|
||||
{t('No audit events yet for this deployment.')}
|
||||
</p>
|
||||
) : (
|
||||
<ol className='mt-4 max-h-[400px] space-y-2 overflow-y-auto pe-1'>
|
||||
{data.map((entry, idx) => {
|
||||
const eventName = String(
|
||||
entry.event || entry.action || '(unknown)'
|
||||
)
|
||||
const occurred = String(entry.occurred_at || '')
|
||||
const correlation = String(entry.correlation_id || '')
|
||||
const level = classifyEventLevel(entry)
|
||||
const dotClass =
|
||||
level === 'error'
|
||||
? 'bg-rose-500 ring-rose-500/30'
|
||||
: level === 'warn'
|
||||
? 'bg-amber-500 ring-amber-500/30'
|
||||
: 'bg-primary ring-primary/30'
|
||||
return (
|
||||
<li
|
||||
key={
|
||||
(entry.event_id as string) ||
|
||||
`${eventName}-${idx}-${occurred}`
|
||||
}
|
||||
className='flex items-start gap-3 rounded-lg border border-dashed border-[color-mix(in_oklch,var(--primary)_14%,var(--border))] bg-background/40 p-2'
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'mt-1 inline-block h-2.5 w-2.5 shrink-0 rounded-full ring-2',
|
||||
dotClass
|
||||
)}
|
||||
/>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<p className='font-mono text-xs text-foreground'>
|
||||
{eventName}
|
||||
</p>
|
||||
<p className='mt-0.5 text-[11px] text-muted-foreground'>
|
||||
{occurred || '—'}
|
||||
{correlation && (
|
||||
<>
|
||||
<span className='mx-2'>·</span>
|
||||
<span className='font-mono'>{correlation}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Events page
|
||||
// =============================================================================
|
||||
@@ -1168,13 +1306,22 @@ export function AgnetSKSourcesPage() {
|
||||
const [summaryOpen, setSummaryOpen] = useState(false)
|
||||
|
||||
const projectSources = resources.filter((r) => r.type === 'git')
|
||||
const skSources = resources.filter(
|
||||
(r) => r.type === 'sk' || r.type === 'project_doc'
|
||||
)
|
||||
// M3 — project_doc is now a first-class binding type with its own
|
||||
// step, separated from SK skill packs. The docs/04 §"绑定资源"
|
||||
// contract lists "项目文档" as a distinct category alongside Git
|
||||
// and SK; the previous merged "SK or project docs" step blurred
|
||||
// that line and made users wonder which one they were picking.
|
||||
const docSources = resources.filter((r) => r.type === 'project_doc')
|
||||
const skSources = resources.filter((r) => r.type === 'sk')
|
||||
const cloudSources = resources.filter(
|
||||
(r) => r.type === 'cloud_account' || r.type === 'cloud_resource'
|
||||
)
|
||||
|
||||
// skSources used to include project_doc rows for "do we have any
|
||||
// doc-ish source" gating downstream — preserve that contract by
|
||||
// exposing a combined view for any callers that still want it.
|
||||
const skOrDocSources = [...skSources, ...docSources]
|
||||
|
||||
const steps = [
|
||||
{
|
||||
key: 'code',
|
||||
@@ -1187,11 +1334,23 @@ export function AgnetSKSourcesPage() {
|
||||
},
|
||||
{
|
||||
key: 'docs',
|
||||
title: t('Connect SK or project docs'),
|
||||
title: t('Connect project docs'),
|
||||
summary:
|
||||
docSources.length > 0
|
||||
? t('{{n}} doc source connected', { n: docSources.length })
|
||||
: t(
|
||||
'Link product requirements, design docs or wiki repos so Agnet has project context.'
|
||||
),
|
||||
done: docSources.length > 0,
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
key: 'sk',
|
||||
title: t('Connect SK skill packs'),
|
||||
summary:
|
||||
skSources.length > 0
|
||||
? t('{{n}} source connected', { n: skSources.length })
|
||||
: t('Pick an existing SK / docs repository or skip'),
|
||||
? t('{{n}} SK source connected', { n: skSources.length })
|
||||
: t('Pick a reusable skill / agent toolset repository, or skip.'),
|
||||
done: skSources.length > 0,
|
||||
optional: true,
|
||||
},
|
||||
@@ -1222,7 +1381,7 @@ export function AgnetSKSourcesPage() {
|
||||
const completed = steps.filter((s) => s.done).length
|
||||
const total = steps.length
|
||||
const prereqsDone =
|
||||
projectSources.length > 0 || skSources.length > 0
|
||||
projectSources.length > 0 || skOrDocSources.length > 0
|
||||
|
||||
return (
|
||||
<PageSurface
|
||||
@@ -1271,13 +1430,26 @@ export function AgnetSKSourcesPage() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{step.key === 'code' || step.key === 'docs' ? (
|
||||
{step.key === 'code' ||
|
||||
step.key === 'docs' ||
|
||||
step.key === 'sk' ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant={step.done ? 'ghost' : 'default'}
|
||||
size='sm'
|
||||
className='shrink-0 rounded-xl'
|
||||
onClick={() => setAdvancedOpen(true)}
|
||||
onClick={() => {
|
||||
// Pre-select the right resource type so users
|
||||
// don't accidentally bind a doc as a git source.
|
||||
const presetType =
|
||||
step.key === 'docs'
|
||||
? 'project_doc'
|
||||
: step.key === 'sk'
|
||||
? 'sk'
|
||||
: 'git'
|
||||
setResourceForm((v) => ({ ...v, type: presetType }))
|
||||
setAdvancedOpen(true)
|
||||
}}
|
||||
>
|
||||
{step.done ? t('Manage') : t('Connect')}
|
||||
</Button>
|
||||
@@ -1548,7 +1720,10 @@ export function AgnetSKSourcesPage() {
|
||||
<RecommendationSummaryDialog
|
||||
onClose={() => setSummaryOpen(false)}
|
||||
projectSources={projectSources}
|
||||
skSources={skSources}
|
||||
// Pre-M3 the summary dialog received a combined SK + docs
|
||||
// list; preserve that so the summary still shows every
|
||||
// doc-ish binding even after we split the steps.
|
||||
skSources={skOrDocSources}
|
||||
/>
|
||||
)}
|
||||
</PageSurface>
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CheckCircle2, AlertTriangle, RefreshCw, ShieldOff } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SettingsSection } from '../components/settings-section'
|
||||
|
||||
// M7 — admin-only secret-store (OpenBao / Vault) status snapshot.
|
||||
// Pure read; deliberately exposes only:
|
||||
// - configuration state (env vars set?)
|
||||
// - reachability
|
||||
// - sealed / initialized / standby flags
|
||||
// - version string
|
||||
// - human-readable message
|
||||
// Per product docs §13.9 we MUST NOT show any secret names or values
|
||||
// — those don't even leave the backend. The status endpoint is mounted
|
||||
// behind AdminAuth; this UI is rendered inside system-settings which
|
||||
// is already admin-gated.
|
||||
|
||||
type StoreStatus = {
|
||||
configured: boolean
|
||||
reachable: boolean
|
||||
initialized?: boolean
|
||||
sealed?: boolean
|
||||
standby?: boolean
|
||||
version?: string
|
||||
message?: string
|
||||
checked_at: number
|
||||
}
|
||||
|
||||
type ApiEnvelope<T> = {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
function formatTs(ms: number): string {
|
||||
if (!ms) return '—'
|
||||
const d = new Date(ms)
|
||||
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString()
|
||||
}
|
||||
|
||||
export function SecretStoreSection() {
|
||||
const { t } = useTranslation()
|
||||
const [status, setStatus] = useState<StoreStatus | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string>('')
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.get<ApiEnvelope<StoreStatus>>(
|
||||
'/api/secret-store/status'
|
||||
)
|
||||
if (!res.data?.success || !res.data.data) {
|
||||
setError(res.data?.message || t('Failed to read secret store status'))
|
||||
return
|
||||
}
|
||||
setStatus(res.data.data)
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
setError(msg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch once on mount; admin clicks refresh for subsequent polls.
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Pick the colour-coded summary pill based on the three operational
|
||||
// states the backend can return. Order matters: not-configured beats
|
||||
// reachability, and sealed beats reachable-but-otherwise-fine.
|
||||
let toneClass = 'bg-muted/40 text-muted-foreground ring-border/60'
|
||||
let toneIcon = ShieldOff
|
||||
let toneLabel = t('Unknown')
|
||||
if (status) {
|
||||
if (!status.configured) {
|
||||
toneClass = 'bg-muted/40 text-muted-foreground ring-border/60'
|
||||
toneIcon = ShieldOff
|
||||
toneLabel = t('Not configured')
|
||||
} else if (!status.reachable) {
|
||||
toneClass = 'bg-rose-500/15 text-rose-400 ring-rose-500/30'
|
||||
toneIcon = AlertTriangle
|
||||
toneLabel = t('Unreachable')
|
||||
} else if (status.sealed) {
|
||||
toneClass = 'bg-amber-500/15 text-amber-400 ring-amber-500/30'
|
||||
toneIcon = AlertTriangle
|
||||
toneLabel = t('Sealed')
|
||||
} else if (!status.initialized) {
|
||||
toneClass = 'bg-amber-500/15 text-amber-400 ring-amber-500/30'
|
||||
toneIcon = AlertTriangle
|
||||
toneLabel = t('Not initialized')
|
||||
} else {
|
||||
toneClass = 'bg-emerald-500/15 text-emerald-400 ring-emerald-500/30'
|
||||
toneIcon = CheckCircle2
|
||||
toneLabel = t('Healthy')
|
||||
}
|
||||
}
|
||||
const ToneIcon = toneIcon
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('Secret vault status')}
|
||||
description={t(
|
||||
'Heicode stores long-lived credentials in OpenBao / Vault. This panel surfaces health and seal state for the operator — secret names and values are NEVER displayed here.'
|
||||
)}
|
||||
>
|
||||
<div className='rounded-lg border bg-card/40 p-4'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ring-1 ring-inset',
|
||||
toneClass
|
||||
)}
|
||||
>
|
||||
<ToneIcon className='h-3.5 w-3.5' />
|
||||
{toneLabel}
|
||||
</span>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => void refresh()}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn('h-3.5 w-3.5 me-1', loading && 'animate-spin')}
|
||||
/>
|
||||
{t('Refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className='mt-3 rounded-md border border-rose-500/30 bg-rose-500/5 p-2 text-xs text-rose-400'>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<div className='mt-4 grid gap-3 sm:grid-cols-2'>
|
||||
<Field label={t('Configured')} value={status.configured ? 'Yes' : 'No'} />
|
||||
<Field
|
||||
label={t('Reachable')}
|
||||
value={
|
||||
status.configured ? (status.reachable ? 'Yes' : 'No') : '—'
|
||||
}
|
||||
/>
|
||||
<Field
|
||||
label={t('Initialized')}
|
||||
value={
|
||||
status.reachable
|
||||
? status.initialized
|
||||
? 'Yes'
|
||||
: 'No'
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
<Field
|
||||
label={t('Sealed')}
|
||||
value={
|
||||
status.reachable ? (status.sealed ? 'Yes' : 'No') : '—'
|
||||
}
|
||||
/>
|
||||
<Field
|
||||
label={t('Standby')}
|
||||
value={
|
||||
status.reachable ? (status.standby ? 'Yes' : 'No') : '—'
|
||||
}
|
||||
/>
|
||||
<Field label={t('Version')} value={status.version || '—'} />
|
||||
<Field
|
||||
label={t('Last checked')}
|
||||
value={formatTs(status.checked_at)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status?.message && (
|
||||
<p className='mt-3 rounded-md border border-dashed border-border/60 bg-background/40 p-2 text-xs text-muted-foreground'>
|
||||
{status.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status && !status.configured && (
|
||||
<p className='mt-3 text-xs text-muted-foreground'>
|
||||
{t(
|
||||
'To enable: set OPENBAO_ADDR + OPENBAO_TOKEN (or VAULT_*) env vars on the Manager container and restart. The status panel will pick up the connection on the next refresh.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className='text-[11px] uppercase tracking-[0.1em] text-muted-foreground'>
|
||||
{label}
|
||||
</p>
|
||||
<p className='mt-0.5 font-mono text-sm text-foreground'>{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+10
@@ -10,6 +10,7 @@ import { HeaderNavigationSection } from './header-navigation-section'
|
||||
import { LogSettingsSection } from './log-settings-section'
|
||||
import { NoticeSection } from './notice-section'
|
||||
import { PerformanceSection } from './performance-section'
|
||||
import { SecretStoreSection } from './secret-store-section'
|
||||
import { SidebarModulesSection } from './sidebar-modules-section'
|
||||
import { UpdateCheckerSection } from './update-checker-section'
|
||||
|
||||
@@ -79,6 +80,15 @@ const MAINTENANCE_SECTIONS = [
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
// M7 — secret-store (OpenBao / Vault) status panel. Read-only,
|
||||
// shows seal state + reachability; never exposes secret names
|
||||
// or plaintext values.
|
||||
id: 'secret-store',
|
||||
titleKey: 'Secret vault status',
|
||||
descriptionKey: 'OpenBao / Vault health and seal state',
|
||||
build: (_settings: MaintenanceSettings) => <SecretStoreSection />,
|
||||
},
|
||||
{
|
||||
id: 'performance',
|
||||
titleKey: 'Performance',
|
||||
|
||||
+29
-1
@@ -4108,6 +4108,34 @@
|
||||
"When the platform calls third-party models (OpenAI / Anthropic / Google etc.), your inputs and the model outputs are processed by the corresponding model vendor. Each vendor sets its own privacy, retention and training-use terms. Heicode does NOT modify those terms and cannot make any privacy promises on behalf of the vendors. Please review the vendor terms before submitting sensitive content.": "When the platform calls third-party models (OpenAI / Anthropic / Google etc.), your inputs and the model outputs are processed by the corresponding model vendor. Each vendor sets its own privacy, retention and training-use terms. Heicode does NOT modify those terms and cannot make any privacy promises on behalf of the vendors. Please review the vendor terms before submitting sensitive content.",
|
||||
"I have read and understood both notices above. I am responsible for backing up my own data and reviewing the model vendor terms.": "I have read and understood both notices above. I am responsible for backing up my own data and reviewing the model vendor terms.",
|
||||
"Decline and sign out": "Decline and sign out",
|
||||
"I agree, continue": "I agree, continue"
|
||||
"I agree, continue": "I agree, continue",
|
||||
"Audit timeline": "Audit timeline",
|
||||
"Every observable transition for this deployment. Survives container restarts (stored in DB).": "Every observable transition for this deployment. Survives container restarts (stored in DB).",
|
||||
"events": "events",
|
||||
"No audit events yet for this deployment.": "No audit events yet for this deployment.",
|
||||
"Connect project docs": "Connect project docs",
|
||||
"{{n}} doc source connected": "{{n}} doc source connected",
|
||||
"Link product requirements, design docs or wiki repos so Agnet has project context.": "Link product requirements, design docs or wiki repos so Agnet has project context.",
|
||||
"Connect SK skill packs": "Connect SK skill packs",
|
||||
"{{n}} SK source connected": "{{n}} SK source connected",
|
||||
"Pick a reusable skill / agent toolset repository, or skip.": "Pick a reusable skill / agent toolset repository, or skip.",
|
||||
"Secret vault status": "Secret vault status",
|
||||
"OpenBao / Vault health and seal state": "OpenBao / Vault health and seal state",
|
||||
"Heicode stores long-lived credentials in OpenBao / Vault. This panel surfaces health and seal state for the operator — secret names and values are NEVER displayed here.": "Heicode stores long-lived credentials in OpenBao / Vault. This panel surfaces health and seal state for the operator — secret names and values are NEVER displayed here.",
|
||||
"Not configured": "Not configured",
|
||||
"Unreachable": "Unreachable",
|
||||
"Sealed": "Sealed",
|
||||
"Not initialized": "Not initialized",
|
||||
"Healthy": "Healthy",
|
||||
"Unknown": "Unknown",
|
||||
"Failed to read secret store status": "Failed to read secret store status",
|
||||
"Refresh": "Refresh",
|
||||
"Configured": "Configured",
|
||||
"Reachable": "Reachable",
|
||||
"Initialized": "Initialized",
|
||||
"Standby": "Standby",
|
||||
"Version": "Version",
|
||||
"Last checked": "Last checked",
|
||||
"To enable: set OPENBAO_ADDR + OPENBAO_TOKEN (or VAULT_*) env vars on the Manager container and restart. The status panel will pick up the connection on the next refresh.": "To enable: set OPENBAO_ADDR + OPENBAO_TOKEN (or VAULT_*) env vars on the Manager container and restart. The status panel will pick up the connection on the next refresh."
|
||||
}
|
||||
}
|
||||
|
||||
+29
-1
@@ -4108,6 +4108,34 @@
|
||||
"When the platform calls third-party models (OpenAI / Anthropic / Google etc.), your inputs and the model outputs are processed by the corresponding model vendor. Each vendor sets its own privacy, retention and training-use terms. Heicode does NOT modify those terms and cannot make any privacy promises on behalf of the vendors. Please review the vendor terms before submitting sensitive content.": "平台调用各家模型(OpenAI / Anthropic / Google 等)时,你输入的内容和模型返回的输出会经过对应原厂处理。**这些数据的隐私、保留期、是否用于训练等条款由各模型原厂自行决定**,Heicode 不修改原厂条款,也无法代表原厂做任何承诺。提交敏感内容前请先阅读对应原厂的条款。",
|
||||
"I have read and understood both notices above. I am responsible for backing up my own data and reviewing the model vendor terms.": "我已阅读并理解上述两点。我会自行备份数据,并自行阅读相关模型原厂的条款。",
|
||||
"Decline and sign out": "拒绝并退出",
|
||||
"I agree, continue": "我已知悉,继续使用"
|
||||
"I agree, continue": "我已知悉,继续使用",
|
||||
"Audit timeline": "审计时间线",
|
||||
"Every observable transition for this deployment. Survives container restarts (stored in DB).": "本次部署所有可观察的状态变化,容器重启不丢(落到数据库)。",
|
||||
"events": "条事件",
|
||||
"No audit events yet for this deployment.": "该部署暂无审计事件。",
|
||||
"Connect project docs": "绑定项目文档",
|
||||
"{{n}} doc source connected": "已绑定 {{n}} 份项目文档",
|
||||
"Link product requirements, design docs or wiki repos so Agnet has project context.": "绑定需求文档、设计文档或 wiki 仓库,让 Agnet 拿到项目上下文。",
|
||||
"Connect SK skill packs": "绑定 SK 技能包",
|
||||
"{{n}} SK source connected": "已绑定 {{n}} 个 SK 来源",
|
||||
"Pick a reusable skill / agent toolset repository, or skip.": "选一个可复用的技能包 / Agent 工具集仓库,也可以跳过。",
|
||||
"Secret vault status": "密钥保管器状态",
|
||||
"OpenBao / Vault health and seal state": "OpenBao / Vault 的健康状态和封存状态",
|
||||
"Heicode stores long-lived credentials in OpenBao / Vault. This panel surfaces health and seal state for the operator — secret names and values are NEVER displayed here.": "Heicode 把长期凭证存在 OpenBao / Vault 里。这个面板只给运维同事看健康状态、是否封存——**永远不会**在这里显示密钥名称或明文。",
|
||||
"Not configured": "未配置",
|
||||
"Unreachable": "不可达",
|
||||
"Sealed": "已封存",
|
||||
"Not initialized": "未初始化",
|
||||
"Healthy": "正常",
|
||||
"Unknown": "未知",
|
||||
"Failed to read secret store status": "读取密钥保管器状态失败",
|
||||
"Refresh": "刷新",
|
||||
"Configured": "已配置",
|
||||
"Reachable": "可达",
|
||||
"Initialized": "已初始化",
|
||||
"Standby": "备用模式",
|
||||
"Version": "版本",
|
||||
"Last checked": "最近检查",
|
||||
"To enable: set OPENBAO_ADDR + OPENBAO_TOKEN (or VAULT_*) env vars on the Manager container and restart. The status panel will pick up the connection on the next refresh.": "启用方法:在 Manager 容器上配置 OPENBAO_ADDR + OPENBAO_TOKEN 环境变量(或 VAULT_*),然后重启容器。状态面板会在下次刷新时自动识别。"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user