Compare commits

...

10 Commits

15 changed files with 179 additions and 134 deletions

View File

@@ -1,3 +1,3 @@
825448
825449
825463
833504
833505
833519

View File

@@ -1,7 +1,7 @@
import os
import yaml
import logging
from .config_loader import load_config, get_config
from .config_loader import load_config as loader_load_config, get_config as loader_get_config
log = logging.getLogger("ainventory")
@@ -52,8 +52,8 @@ class ConfigManager:
log.info(f"✅ Updated {path} with new values.")
# Reload the global config
load_config()
return get_config()
loader_load_config()
return loader_get_config()
except Exception as e:
log.error(f"❌ Failed to write {path}: {e}")
raise
@@ -72,10 +72,78 @@ class ConfigManager:
except Exception:
return False
@staticmethod
def get_secrets_path():
"""Returns the absolute path to secrets.yaml."""
base_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(base_dir)
return os.path.join(project_root, "config", "secrets.yaml")
@staticmethod
def update_secrets(updates: dict) -> dict:
"""Update secrets.yaml with new values (flattened structure)."""
path = ConfigManager.get_secrets_path()
current_secrets = {}
if os.path.exists(path):
try:
with open(path, 'r', encoding='utf-8') as f:
current_secrets = yaml.safe_load(f) or {}
except Exception as e:
log.error(f"❌ Failed to read {path}: {e}")
# Update flattened secrets
current_secrets.update(updates)
try:
with open(path, 'w', encoding='utf-8') as f:
# Header for clarity
f.write("# TFM aInventory - Managed Secrets (Updated via Admin UI)\n")
yaml.dump(current_secrets, f, default_flow_style=False, sort_keys=True)
log.info(f"✅ Updated {path} with new secrets.")
# Reload the global config
loader_load_config()
return loader_get_config()
except Exception as e:
log.error(f"❌ Failed to write {path}: {e}")
raise
@staticmethod
def update_keys(updates: dict) -> dict:
"""
Intelligent key update: routes sensitive AI keys to secrets.yaml
and others to backend.yaml.
"""
secrets_updates = {}
backend_updates = {}
sensitive_keys = ["GEMINI_API_KEY", "CLAUDE_API_KEY", "JWT_SECRET_KEY", "LDAP_PASSWORD"]
for key, val in updates.items():
if key in sensitive_keys:
secrets_updates[key] = val
else:
# Non-sensitive or complex nested settings
backend_updates[key] = val
if secrets_updates:
ConfigManager.update_secrets(secrets_updates)
if backend_updates:
ConfigManager.update_config(backend_updates)
return loader_get_config()
@staticmethod
def get_config() -> dict:
"""Return the current global configuration."""
return loader_get_config()
@staticmethod
def get_masked_key(key_name: str):
"""Returns a masked version of a configuration value or env var."""
config = get_config()
config = loader_get_config()
# Try to find in config first
val = None
@@ -113,6 +181,10 @@ def update_config(updates: dict) -> dict:
"""Update backend.yaml with new values and return updated config."""
return ConfigManager.update_config(updates)
def get_config() -> dict:
"""Return the current global configuration."""
return loader_get_config()
def validate_config_file() -> bool:
"""Validate backend.yaml syntax and required fields."""
return ConfigManager.validate_config_file()

View File

@@ -67,8 +67,11 @@ def get_ai_config(
current_admin: auth.TokenData = Depends(auth.get_current_admin)
):
"""Check AI provider status and active provider."""
gemini_key = os.environ.get("GEMINI_API_KEY")
claude_key = os.environ.get("CLAUDE_API_KEY")
config = ConfigManager.get_config() # Alias for config_loader.get_config
ai_config = config.get("ai", {})
gemini_key = ai_config.get("gemini_api_key")
claude_key = ai_config.get("claude_api_key")
provider_setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_provider").first()
active_provider = provider_setting.value if provider_setting else "gemini"
@@ -112,10 +115,14 @@ def update_ai_keys(
if updates:
ConfigManager.update_keys(updates)
# Re-load config to return accurate status
config = ConfigManager.get_config()
ai_cfg = config.get("ai", {})
return {
"status": "success",
"gemini_configured": bool(os.environ.get("GEMINI_API_KEY")),
"claude_configured": bool(os.environ.get("CLAUDE_API_KEY"))
"gemini_configured": bool(ai_cfg.get("gemini_api_key")),
"claude_configured": bool(ai_cfg.get("claude_api_key"))
}

View File

@@ -5,7 +5,7 @@ Supports CSV and Excel formats.
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from backend.database import get_db
@@ -70,20 +70,16 @@ async def export_inventory_snapshot(
# Return file response
if format_type == "csv":
# For CSV, use FileResponse with bytes
import io
return FileResponse(
return StreamingResponse(
io.BytesIO(content.encode("utf-8")),
media_type=media_type,
filename=filename,
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
else:
# For Excel, content is already bytes
import io
return FileResponse(
return StreamingResponse(
io.BytesIO(content),
media_type=media_type,
filename=filename,
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
@@ -126,22 +122,20 @@ async def export_audit_trail(
# Return file response
if format_type == "csv":
import io
return FileResponse(
return StreamingResponse(
io.BytesIO(content.encode("utf-8")),
media_type=media_type,
filename=filename,
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
else:
import io
return FileResponse(
return StreamingResponse(
io.BytesIO(content),
media_type=media_type,
filename=filename,
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
@router.get("/db/export")
@router.get("/reports/export")
async def export_db(
format: str = Query("csv", description="Export format: csv or xlsx"),
type: str = Query("inventory", description="Export type: inventory, audit, or combined"),
@@ -226,14 +220,14 @@ async def export_db(
# Return file response
if format_type == "csv":
return FileResponse(
return StreamingResponse(
io.BytesIO(content.encode("utf-8")),
media_type=media_type,
filename=filename,
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
else:
return FileResponse(
return StreamingResponse(
io.BytesIO(content),
media_type=media_type,
filename=filename,
headers={"Content-Disposition": f"attachment; filename={filename}"}
)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -111,10 +111,6 @@ application:
# Environment: BACKEND_APPLICATION_LOGS_DIR or LOGS_DIR
logs_dir: "./logs"
# Comma-separated list of extra allowed CORS origins (IPs/FQDNs)
# Environment: BACKEND_APPLICATION_CORS_ORIGINS or EXTRA_ALLOWED_ORIGINS
cors_origins: "http://localhost:8917"
# --- Feature Flags ---
features:
# Enable AI image extraction and OCR?

View File

@@ -6,10 +6,8 @@
# --- API Connection ---
api:
# Base URL of the backend API
# Default: http://localhost:8916
# Environment: FRONTEND_API_BACKEND_URL or BACKEND_URL
backend_url: "http://localhost:8916"
# Note: backend_url is dynamically injected by the launcher script
# based on network.yaml settings to ensure SSOT integrity.
# API timeout in milliseconds
# Default: 30000 (30 seconds)

View File

@@ -38,36 +38,18 @@ ssl:
certificate_path: ""
# Path to SSL private key
# Environment: NETWORK_SSL_KEY_PATH
# Path to SSL private key
key_path: ""
# --- Proxy (Caddy) Configuration ---
proxy:
# Caddy log level (debug|info|warn|error)
# Default: info
# Environment: NETWORK_PROXY_CADDY_LOG_LEVEL
caddy_log_level: "info"
# --- Master Infrastructure Settings ---
# Establish this file as the SSOT for network topology.
application:
# The IP address or hostname of the server (Used for CORS and Frontend API)
# Default: localhost
# Environment: SERVER_IP
server_ip: "localhost"
# Maximum timeout for reading requests in seconds
# Default: 60
# Environment: NETWORK_PROXY_READ_TIMEOUT_S
proxy_read_timeout_s: 60
# Comma-separated list of extra allowed CORS origins (IPs/FQDNs/Subnets)
# Environment: EXTRA_ALLOWED_ORIGINS
cors_origins: ""
# Maximum request body size in MB
# Default: 10
# Environment: NETWORK_PROXY_MAX_REQUEST_SIZE_MB
max_request_size_mb: 10
# --- CORS Policies ---
cors:
# Comma-separated list of allowed origins
# Environment: NETWORK_CORS_ALLOWED_ORIGINS or EXTRA_ALLOWED_ORIGINS
allowed_origins: "*"
# Allowed HTTP methods
# Environment: NETWORK_CORS_ALLOWED_METHODS
allowed_methods: "GET,POST,PUT,DELETE,OPTIONS"
# Allowed HTTP headers
# Environment: NETWORK_CORS_ALLOWED_HEADERS
allowed_headers: "Authorization,Content-Type,Accept"

View File

@@ -1,6 +1,6 @@
{
"version": "1.14.10",
"last_build": "2026-04-23-1358",
"version": "1.14.15",
"last_build": "2026-04-23-1514",
"codename": "ConfigCore",
"commit": "a5a460e7"
"commit": "76f0e83a"
}

View File

@@ -1,136 +1,132 @@
"use client";
import { useState } from "react";
import { FileDown, Loader2 } from "lucide-react";
import { FileDown, Loader2, FileSpreadsheet, FileText } from "lucide-react";
import { useExport } from "@/hooks/useExport";
import { Toast } from "@/components/Toast";
import { toast } from "react-hot-toast";
export function ExportPanel() {
const { exportSnapshot, exportAuditTrail, isLoading, error } = useExport();
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const handleExportSnapshot = async (format: "csv" | "xlsx") => {
try {
setSuccessMessage(null);
await exportSnapshot(format);
const formatName = format === "csv" ? "CSV" : "Excel";
setSuccessMessage(`Inventory snapshot exported as ${formatName}`);
setTimeout(() => setSuccessMessage(null), 4000);
toast.success(`Inventory snapshot exported as ${formatName}`);
} catch (err) {
// Error handled by useExport hook
// Error handled by hook
}
};
const handleExportAuditTrail = async (format: "csv" | "xlsx") => {
try {
setSuccessMessage(null);
await exportAuditTrail(format);
const formatName = format === "csv" ? "CSV" : "Excel";
setSuccessMessage(`Audit trail exported as ${formatName}`);
setTimeout(() => setSuccessMessage(null), 4000);
toast.success(`Audit trail exported as ${formatName}`);
} catch (err) {
// Error handled by useExport hook
// Error handled by hook
}
};
return (
<div className="space-y-6">
<div className="rounded-lg border border-slate-200 bg-white p-6">
<div className="mb-6 flex items-center gap-3">
<div className="rounded-lg bg-primary/10 p-4 border border-primary/20">
<FileDown className="text-primary" size={24} />
</div>
<div data-testid="admin-tab-exports" className="p-4 md:p-6 bg-surface border border-slate-800 rounded-3xl space-y-4 md:space-y-6">
<header className="flex items-center gap-3">
<div className="p-3 md:p-4 bg-indigo-500/10 rounded-2xl text-indigo-400 border border-indigo-500/20 shadow-xl shadow-indigo-500/5">
<FileDown size={28} className="md:w-8 md:h-8" />
</div>
<div>
<h2 className="text-xl md:text-2xl font-normal text-white">Export & Reports</h2>
<p className="text-[10px] md:text-xs text-muted font-normal tracking-tight mt-0.5">
Download inventory snapshots and audit trails
</p>
</div>
</header>
<div className="grid md:grid-cols-2 gap-4">
{/* Inventory Snapshot Section */}
<div className="p-4 bg-slate-800/40 border border-slate-800 rounded-2xl space-y-4">
<div>
<h2 className="text-2xl font-normal">Export & Reports</h2>
<p className="text-xs text-slate-500">
Download inventory snapshots and audit trails in CSV or Excel formats
<h3 className="text-sm font-normal text-secondary">Inventory Snapshot</h3>
<p className="text-xs text-muted mt-0.5">
Export current inventory state with all item details
</p>
</div>
</div>
{/* Inventory Snapshot Section */}
<div className="mb-8 space-y-3">
<h3 className="font-normal text-slate-700">Inventory Snapshot</h3>
<p className="text-sm text-slate-500">
Export current inventory state with all item details
</p>
<div className="flex flex-col gap-2 sm:flex-row">
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => handleExportSnapshot("csv")}
disabled={isLoading}
className="flex items-center justify-center gap-2 rounded-md bg-blue-500 px-4 py-2 text-sm font-normal text-white hover:bg-blue-600 disabled:bg-slate-400 disabled:cursor-not-allowed transition-colors"
className="bg-surface border border-slate-800 hover:border-primary/50 hover:bg-slate-800/50 text-white font-normal py-2.5 px-3 rounded-xl transition-all active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none flex items-center justify-center gap-2 text-xs focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none group"
aria-label="Export inventory snapshot as CSV"
>
{isLoading ? (
<Loader2 size={16} className="animate-spin" />
<Loader2 size={16} className="animate-spin text-primary" />
) : (
<FileDown size={16} />
<FileText size={16} className="text-blue-400 group-hover:text-blue-300 transition-colors" />
)}
{isLoading ? "Exporting..." : "Export as CSV"}
<span>CSV</span>
</button>
<button
onClick={() => handleExportSnapshot("xlsx")}
disabled={isLoading}
className="flex items-center justify-center gap-2 rounded-md bg-green-500 px-4 py-2 text-sm font-normal text-white hover:bg-green-600 disabled:bg-slate-400 disabled:cursor-not-allowed transition-colors"
className="bg-surface border border-slate-800 hover:border-primary/50 hover:bg-slate-800/50 text-white font-normal py-2.5 px-3 rounded-xl transition-all active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none flex items-center justify-center gap-2 text-xs focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none group"
aria-label="Export inventory snapshot as Excel"
>
{isLoading ? (
<Loader2 size={16} className="animate-spin" />
<Loader2 size={16} className="animate-spin text-primary" />
) : (
<FileDown size={16} />
<FileSpreadsheet size={16} className="text-emerald-400 group-hover:text-emerald-300 transition-colors" />
)}
{isLoading ? "Exporting..." : "Export as Excel"}
<span>Excel</span>
</button>
</div>
</div>
{/* Audit Trail Section */}
<div className="space-y-3">
<h3 className="font-normal text-slate-700">Audit Trail</h3>
<p className="text-sm text-slate-500">
Export complete audit log of all system actions and changes
</p>
<div className="flex flex-col gap-2 sm:flex-row">
<div className="p-4 bg-slate-800/40 border border-slate-800 rounded-2xl space-y-4">
<div>
<h3 className="text-sm font-normal text-secondary">Audit Trail</h3>
<p className="text-xs text-muted mt-0.5">
Complete log of system actions and changes
</p>
</div>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => handleExportAuditTrail("csv")}
disabled={isLoading}
className="flex items-center justify-center gap-2 rounded-md bg-blue-500 px-4 py-2 text-sm font-normal text-white hover:bg-blue-600 disabled:bg-slate-400 disabled:cursor-not-allowed transition-colors"
className="bg-surface border border-slate-800 hover:border-primary/50 hover:bg-slate-800/50 text-white font-normal py-2.5 px-3 rounded-xl transition-all active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none flex items-center justify-center gap-2 text-xs focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none group"
aria-label="Export audit trail as CSV"
>
{isLoading ? (
<Loader2 size={16} className="animate-spin" />
<Loader2 size={16} className="animate-spin text-primary" />
) : (
<FileDown size={16} />
<FileText size={16} className="text-blue-400 group-hover:text-blue-300 transition-colors" />
)}
{isLoading ? "Exporting..." : "Export as CSV"}
<span>CSV</span>
</button>
<button
onClick={() => handleExportAuditTrail("xlsx")}
disabled={isLoading}
className="flex items-center justify-center gap-2 rounded-md bg-green-500 px-4 py-2 text-sm font-normal text-white hover:bg-green-600 disabled:bg-slate-400 disabled:cursor-not-allowed transition-colors"
className="bg-surface border border-slate-800 hover:border-primary/50 hover:bg-slate-800/50 text-white font-normal py-2.5 px-3 rounded-xl transition-all active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none flex items-center justify-center gap-2 text-xs focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none group"
aria-label="Export audit trail as Excel"
>
{isLoading ? (
<Loader2 size={16} className="animate-spin" />
<Loader2 size={16} className="animate-spin text-primary" />
) : (
<FileDown size={16} />
<FileSpreadsheet size={16} className="text-emerald-400 group-hover:text-emerald-300 transition-colors" />
)}
{isLoading ? "Exporting..." : "Export as Excel"}
<span>Excel</span>
</button>
</div>
</div>
</div>
{/* Status Messages */}
{error && (
<Toast
type="error"
message={`Export failed: ${error}`}
onClose={() => {}}
/>
)}
{successMessage && (
<Toast type="success" message={successMessage} onClose={() => {}} />
<div className="p-3 bg-rose-500/10 border border-rose-500/20 rounded-xl text-rose-500 text-[10px] flex items-center gap-2 animate-in fade-in zoom-in duration-300">
<div className="w-1.5 h-1.5 bg-rose-500 rounded-full animate-pulse" />
<span className="font-normal">Error: {error}</span>
</div>
)}
</div>
);

View File

@@ -50,7 +50,7 @@ export function useExport(): UseExportReturn {
try {
const response = await axiosInstance.get(
`/admin/db/export?format=${format}&type=inventory`,
`/admin/reports/export?format=${format}&type=inventory`,
{
responseType: "blob"
}
@@ -83,7 +83,7 @@ export function useExport(): UseExportReturn {
try {
const response = await axiosInstance.get(
`/admin/db/export?format=${format}&type=audit`,
`/admin/reports/export?format=${format}&type=audit`,
{
responseType: "blob"
}