diff --git a/.gitignore b/.gitignore index 98b27fba..69936cbc 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ build/ /data/* !/data/.gitkeep +/data/backups/ /logs/* !/logs/.gitkeep @@ -86,3 +87,5 @@ aInventory-PROD*.zip *.key *.crt *.cert + +__push_ALL_to_remote.sh diff --git a/AI_RULES.md b/AI_RULES.md index 1021a544..7c8ae656 100644 --- a/AI_RULES.md +++ b/AI_RULES.md @@ -52,6 +52,25 @@ For technical architecture, data models, and stack details, refer to [PROJECT_AR 6. Stay on the current branch (`dev`). - *Implementation*: Use `python3 scripts/save_version.py` to ensure consistency. +## 7. UI Fidelity Laws (Strict) +- **Consistent Layout**: All main pages (Inventory, Audit, Admin) MUST use `max-w-7xl` for their primary content container. +- **Unified Headers**: Every functional page MUST have an identical header structure: + - Icon inside a styled box (`p-4 bg-primary/10 rounded-[2rem] border border-primary/20`). + - Title: `text-3xl font-black text-white tracking-tight`. + - Subtitle: `text-xs text-slate-500 font-bold mt-1 tracking-wider`. +- **Typographic Integrity**: + - **NO UPPERCASE** (ALL CAPS) allowed anywhere in the UI (headers, labels, buttons, metadata). + - **NO ITALICS** in headers, titles, or active UI elements. + - Consistent font weight (`font-black`) for main headings. +- **Safety Actions**: + - The **Logout** button MUST be clearly differentiated (e.g., `text-rose-500`) and MUST always require a `window.confirm` before execution. + +## 8. Audit & Deletion Policy +- **Immutability of Logs**: Deleting an `Item` MUST NOT delete its corresponding entries in the `AuditLog` table in the database. +- **Disk Traceability**: Every item deletion MUST be logged to the disk file (`logs/backend.log`) with `USER[id]`, `ITEM[id]`, `Name`, and `PN`. +- **Explicit Confirmation**: Destructive actions (Delete Item from catalog) MUST always trigger a browser-native `window.confirm` before proceeding. + + ## 5. End of Session Protocol - Once a phase/task from `PLAN.md` is completed and verified, move that entry into `dev_docs/PLAN_HISTORY.md`. - After finishing an entire job (including updating session state, architecture logs, and versioning), end your final response on a separate line exactly with: diff --git a/VERSION.json b/VERSION.json index e615d6c8..41592307 100644 --- a/VERSION.json +++ b/VERSION.json @@ -1,8 +1,10 @@ { - "version": "1.3.9", - "last_build": "2026-04-12-0729", - "commit": "0869ab8c", + "version": "1.4.0", + "last_build": "2026-04-12-0940", + "commit": "HEAD", "changelog": [ + "v1.4.0: Final Audit Dashboard (Phase 6), LDAP UI Restoration, environment path fixes for macOS", + "v1.3.9: Maintenance and structural updates", "v1.3.8: Remove .npx_cache/ and scratch/npm_cache/ from git tracking (3350 files purged from index)", "v1.3.7: Security hardening \u2014 expanded .gitignore, removed ldap_config.json from tracking, added example files", "v1.3.6: Scanner UI redesign (autonomous OCR, countdown), Item Type datalist, save-version automation" diff --git a/backend/db_manager.py b/backend/db_manager.py new file mode 100644 index 00000000..8575cff8 --- /dev/null +++ b/backend/db_manager.py @@ -0,0 +1,138 @@ +import os +import shutil +import datetime +import sqlite3 +import logging +from typing import List +from sqlalchemy import text +from sqlalchemy.orm import Session +from .database import DATA_DIR, engine +from . import models, schemas + +logger = logging.getLogger("ainventory") + +BACKUP_DIR = os.path.join(DATA_DIR, "backups") +os.makedirs(BACKUP_DIR, exist_ok=True) + +class DbManager: + @staticmethod + def get_backup_list() -> List[schemas.BackupInfo]: + """List all available backup files with metadata.""" + backups = [] + if not os.path.exists(BACKUP_DIR): + return [] + + for filename in os.listdir(BACKUP_DIR): + if filename.endswith(".db"): + path = os.path.join(BACKUP_DIR, filename) + stats = os.stat(path) + backups.append(schemas.BackupInfo( + filename=filename, + size_bytes=stats.st_size, + created_at=datetime.datetime.fromtimestamp(stats.st_mtime) + )) + + # Sort by creation time descending + backups.sort(key=lambda x: x.created_at, reverse=True) + return backups + + @staticmethod + def get_stats() -> schemas.DatabaseStats: + """Calculate storage statistics for backups.""" + backups = DbManager.get_backup_list() + total_size = sum(b.size_bytes for b in backups) + return schemas.DatabaseStats( + backup_count=len(backups), + total_size_bytes=total_size + ) + + @staticmethod + def create_backup(db: Session, label: str = "manual", user_id: int = None) -> str: + """ + Creates a consistent snapshot of the active database using VACUUM INTO. + Records the action in AuditLog. + """ + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"inventory_{label}_{timestamp}.db" + target_path = os.path.join(BACKUP_DIR, filename) + + try: + # Use SQLite's VACUUM INTO for a safe, non-blocking backup + # We need a clean string path without potential SQL injection (filenames are generated here) + db.execute(text(f"VACUUM INTO '{target_path}'")) + logger.info(f"[DB] Backup created successfully: {filename}") + + # Audit Log + audit = models.AuditLog( + user_id=user_id, + action="DB_BACKUP", + details=f"Backup type: {label}. Created file: {filename}" + ) + db.add(audit) + db.commit() + + # Enforce retention policy + DbManager.enforce_retention(db) + + return filename + except Exception as e: + logger.error(f"[DB] Backup failed: {str(e)}") + db.rollback() + raise Exception(f"Backup failed: {str(e)}") + + @staticmethod + def enforce_retention(db: Session): + """Deletes oldest backups if count exceeds retention limit.""" + try: + # Get retention limit from settings + limit_setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "backup_retention_count").first() + limit = int(limit_setting.value) if limit_setting else 10 # Default to 10 + + backups = DbManager.get_backup_list() + if len(backups) > limit: + to_delete = backups[limit:] + for b in to_delete: + path = os.path.join(BACKUP_DIR, b.filename) + if os.path.exists(path): + os.remove(path) + logger.info(f"[DB] Retention policy: deleted old backup {b.filename}") + except Exception as e: + logger.error(f"[DB] Retention enforcement failed: {str(e)}") + + @staticmethod + def restore_backup(filename: str, db: Session, user_id: int) -> bool: + """ + Restores a database from a backup file. + IMPORTANT: This replaces the primary inventory.db file. + """ + source_path = os.path.join(BACKUP_DIR, filename) + active_db_path = os.path.join(DATA_DIR, "inventory.db") + + if not os.path.exists(source_path): + raise Exception("Backup file not found") + + try: + # 1. Create a safety rollback backup of current state + logger.info("[DB] Creating safety rollback backup before restore...") + DbManager.create_backup(db, label="rollback", user_id=user_id) + + # 2. Close all connections (or as many as possible via pool dispose) + # engine.dispose() drops the current connection pool + engine.dispose() + + # 3. Physically swap files + # Note: shutil.copy2 handles file metadata preservation + shutil.copy2(source_path, active_db_path) + + logger.warning(f"[DB] RESTORE COMPLETED from {filename} by user_id={user_id}") + + # Record the restore in the NEW database (since we just swapped it) + # Re-initializing session logic might be needed but usually next request handles it. + # However, for the current request, the 'db' session is still bound to the OLD file mapping possibly? + # Actually, the file on disk is changed. Next commit might fail or work depending on how sqlite handles it. + # It's safest to return success and let the frontend trigger a reload. + + return True + except Exception as e: + logger.error(f"[DB] Restore failed: {str(e)}") + raise Exception(f"Restore failed: {str(e)}") diff --git a/backend/main.py b/backend/main.py index 53b62829..2cf79ee1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -5,8 +5,9 @@ from slowapi import Limiter from slowapi.util import get_remote_address from . import models from .database import engine -from .routers import items, operations, users, categories +from .routers import items, operations, users, categories, admin_db from .logger import log +from .scheduler import scheduler, sync_scheduler_config # Create the database tables from .database import DATA_DIR, db_path @@ -33,7 +34,7 @@ app.add_middleware( CORSMiddleware, allow_origins=ALLOWED_ORIGINS, allow_credentials=True, - allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], allow_headers=["*"], ) @@ -45,6 +46,13 @@ app.include_router(items.router) app.include_router(operations.router) app.include_router(users.router) app.include_router(categories.router) +app.include_router(admin_db.router) + +@app.on_event("startup") +def startup_event(): + log.info("[STARTUP] Starting background scheduler...") + scheduler.start() + sync_scheduler_config() @app.get("/") def read_root(): diff --git a/backend/models.py b/backend/models.py index 4c6b50a3..b3f3a484 100644 --- a/backend/models.py +++ b/backend/models.py @@ -48,7 +48,7 @@ class AuditLog(Base): __tablename__ = "audit_logs" id = Column(Integer, primary_key=True, index=True) - timestamp = Column(DateTime, default=datetime.datetime.utcnow) + timestamp = Column(DateTime, default=datetime.datetime.now) user_id = Column(Integer, ForeignKey("users.id")) action = Column(String) # e.g., 'CHECK_IN', 'CHECK_OUT', 'CREATE_ITEM' target_item_id = Column(Integer, nullable=True) @@ -64,7 +64,7 @@ class Intervention(Base): id = Column(Integer, primary_key=True, index=True) name = Column(String) status = Column(String, default="ACTIVE") - created_at = Column(DateTime, default=datetime.datetime.utcnow) + created_at = Column(DateTime, default=datetime.datetime.now) items = relationship("InterventionItem", back_populates="intervention") @@ -78,3 +78,9 @@ class InterventionItem(Base): checked_out_quantity = Column(Float, default=0.0) intervention = relationship("Intervention", back_populates="items") + +class SystemSetting(Base): + __tablename__ = "system_settings" + + key = Column(String, primary_key=True, index=True) + value = Column(String) diff --git a/backend/requirements.txt b/backend/requirements.txt index 0bf1fd49..25775e84 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -12,3 +12,4 @@ ldap3>=2.9.1 passlib[bcrypt]>=1.7.4 python-jose[cryptography]>=3.3.0 slowapi>=0.1.9 +apscheduler>=3.10.1 diff --git a/backend/routers/admin_db.py b/backend/routers/admin_db.py new file mode 100644 index 00000000..bb543823 --- /dev/null +++ b/backend/routers/admin_db.py @@ -0,0 +1,105 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List +from .. import models, schemas, auth +from ..database import get_db +from ..db_manager import DbManager +from ..scheduler import sync_scheduler_config + +router = APIRouter( + prefix="/admin/db", + tags=["Admin Database"] +) + +@router.get("/backups", response_model=List[schemas.BackupInfo]) +def get_backups( + db: Session = Depends(get_db), + current_admin: auth.TokenData = Depends(auth.get_current_admin) +): + """List available database backups.""" + return DbManager.get_backup_list() + +@router.get("/stats", response_model=schemas.DatabaseStats) +def get_db_stats( + db: Session = Depends(get_db), + current_admin: auth.TokenData = Depends(auth.get_current_admin) +): + """Get database backup storage statistics.""" + return DbManager.get_stats() + +@router.post("/backup", response_model=schemas.BackupInfo) +def trigger_manual_backup( + db: Session = Depends(get_db), + current_admin: auth.TokenData = Depends(auth.get_current_admin) +): + """Trigger a manual database backup.""" + filename = DbManager.create_backup(db, label="manual", user_id=current_admin.sub) + # Re-fetch the newly created file info + backups = DbManager.get_backup_list() + for b in backups: + if b.filename == filename: + return b + raise HTTPException(status_code=500, detail="Backup created but info not found") + +@router.post("/restore") +def restore_database( + payload: dict, + db: Session = Depends(get_db), + current_admin: auth.TokenData = Depends(auth.get_current_admin) +): + """Restore database from a specific file. DANGEROUS.""" + filename = payload.get("filename") + confirm = payload.get("confirm", False) + + if not filename: + raise HTTPException(status_code=400, detail="Filename required") + if not confirm: + raise HTTPException(status_code=400, detail="Confirmation required") + + try: + success = DbManager.restore_backup(filename, db, user_id=current_admin.sub) + return {"status": "success", "message": f"Database restored from {filename}"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/settings", response_model=schemas.DbSettingsUpdate) +def get_db_settings( + db: Session = Depends(get_db), + current_admin: auth.TokenData = Depends(auth.get_current_admin) +): + """Get database retention and scheduling settings.""" + # Ensure default settings exist + retention = db.query(models.SystemSetting).filter(models.SystemSetting.key == "backup_retention_count").first() + hour = db.query(models.SystemSetting).filter(models.SystemSetting.key == "backup_schedule_hour").first() + freq = db.query(models.SystemSetting).filter(models.SystemSetting.key == "backup_schedule_freq_days").first() + + return { + "retention_count": int(retention.value) if retention else 10, + "schedule_hour": int(hour.value) if hour else 3, + "schedule_freq_days": int(freq.value) if freq else 1 + } + +@router.patch("/settings", response_model=schemas.DbSettingsUpdate) +def update_db_settings( + settings: schemas.DbSettingsUpdate, + db: Session = Depends(get_db), + current_admin: auth.TokenData = Depends(auth.get_current_admin) +): + """Update database settings and re-trigger scheduler sync.""" + pairs = { + "backup_retention_count": str(settings.retention_count), + "backup_schedule_hour": str(settings.schedule_hour), + "backup_schedule_freq_days": str(settings.schedule_freq_days) + } + + for key, val in pairs.items(): + existing = db.query(models.SystemSetting).filter(models.SystemSetting.key == key).first() + if existing: + existing.value = val + else: + db.add(models.SystemSetting(key=key, value=val)) + + db.commit() + # Re-trigger scheduler sync + sync_scheduler_config() + return settings diff --git a/backend/routers/items.py b/backend/routers/items.py index cab8d568..8540d926 100644 --- a/backend/routers/items.py +++ b/backend/routers/items.py @@ -143,11 +143,20 @@ def delete_item( db: Session = Depends(get_db), current_user: auth.TokenData = Depends(auth.get_current_user) ): - """[C-01] Delete item — only for authenticated users.""" + """[C-01] Delete item — only for authenticated users. InterventionItems are cleared; AuditLogs are KEPT for history.""" db_item = db.query(models.Item).filter(models.Item.id == item_id).first() if not db_item: raise HTTPException(status_code=404, detail="Item not found") + # [AUDIT] Log the deletion to disk file via logger.py + from ..logger import log + log.warning(f"USER[{current_user.sub}] DELETING ITEM: ID={item_id}, Name={db_item.name}, PN={db_item.part_number}") + + # [CLEANUP] Delete related InterventionItems to prevent foreign key issues + db.query(models.InterventionItem).filter(models.InterventionItem.item_id == item_id).delete() + + # Audit Logs in database are NOT deleted here to preserve history of actions + db.delete(db_item) db.commit() - return {"message": "Item deleted successfully"} + return {"message": "Item deleted successfully. History logs preserved."} diff --git a/backend/scheduler.py b/backend/scheduler.py new file mode 100644 index 00000000..c1e2d1a9 --- /dev/null +++ b/backend/scheduler.py @@ -0,0 +1,46 @@ +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from .database import SessionLocal +from .db_manager import DbManager +from . import models +import logging + +log = logging.getLogger("ainventory") +scheduler = BackgroundScheduler() + +def scheduled_backup_job(): + """System triggered automated backup.""" + db = SessionLocal() + try: + log.info("[SCHEDULER] Starting automated backup job...") + DbManager.create_backup(db, label="auto", user_id=None) + except Exception as e: + log.error(f"[SCHEDULER] Automated backup failed: {str(e)}") + finally: + db.close() + +def sync_scheduler_config(): + """Read DB settings and update the scheduler job.""" + db = SessionLocal() + try: + hour_s = db.query(models.SystemSetting).filter(models.SystemSetting.key == "backup_schedule_hour").first() + freq_s = db.query(models.SystemSetting).filter(models.SystemSetting.key == "backup_schedule_freq_days").first() + + hour = int(hour_s.value) if hour_s else 3 + freq = int(freq_s.value) if freq_s else 1 + + # Remove existing backup jobs to avoid duplicates + for job in scheduler.get_jobs(): + if job.id == "auto_backup": + scheduler.remove_job(job.id) + + # Add job with cron trigger: trigger at 'hour' every 'freq' days + # Use day='*/freq' for intervals in days + trigger = CronTrigger(hour=hour, minute=0, day=f"*/{freq}") + scheduler.add_job(scheduled_backup_job, trigger, id="auto_backup") + + log.info(f"[SCHEDULER] Policy synced: Every {freq} days at {hour:02d}:00") + except Exception as e: + log.error(f"[SCHEDULER] Failed to sync config: {str(e)}") + finally: + db.close() diff --git a/backend/schemas.py b/backend/schemas.py index 0711bbcc..95fd64da 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -116,3 +116,27 @@ class AuditLogResponse(BaseModel): class Config: from_attributes = True + +# --- System Settings --- +class SystemSettingBase(BaseModel): + key: str + value: str + +class SystemSetting(SystemSettingBase): + class Config: + from_attributes = True + +# --- Database Management --- +class BackupInfo(BaseModel): + filename: str + size_bytes: int + created_at: datetime + +class DatabaseStats(BaseModel): + backup_count: int + total_size_bytes: int + +class DbSettingsUpdate(BaseModel): + retention_count: int + schedule_hour: int + schedule_freq_days: int diff --git a/dev_docs/SESSION_STATE.md b/dev_docs/SESSION_STATE.md index 6402a04f..900b9584 100644 --- a/dev_docs/SESSION_STATE.md +++ b/dev_docs/SESSION_STATE.md @@ -26,6 +26,12 @@ Runtime data (`data/`, `logs/`) is now properly excluded from Git with init-on-f - **`.gitignore`** — Changed `data/` → `data/*` with `!/data/.gitkeep` exception; same for `logs/` - **Git index cleaned** — `data/inventory.db` and `data/ldap_config.json` removed from tracking via `git rm --cached` +### Item Deletion Improvement +- **`frontend/app/page.tsx` & `inventory/page.tsx`** — Moved the "Delete" button (Trash icon) to the modal header. +- The button is now always visible in the item details view, not just during edit mode. +- Implementation uses `handleDeleteItem` which enforces user confirmation via `window.confirm` before removing the item from the DB. + + - **Layout**: Moved all controls OUT of the camera viewport overlay. Camera feed is now 100% unobstructed. - **Automated OCR**: Removed manual "OCR SCAN" button. OCR now runs automatically on a 4-second cycle. diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx index b609beaa..0ad1f793 100644 --- a/frontend/app/admin/page.tsx +++ b/frontend/app/admin/page.tsx @@ -22,7 +22,11 @@ import { Wifi, WifiOff, Layers, - ChevronDown + ChevronDown, + Clock, + HardDrive, + Download, + RotateCcw } from 'lucide-react'; import { toast } from 'react-hot-toast'; import { cn } from '@/lib/utils'; @@ -52,6 +56,12 @@ export default function AdminPage() { const [editingCategory, setEditingCategory] = useState(null); const [editCatForm, setEditCatForm] = useState({ name: '', description: '' }); + // DB Management State + const [backups, setBackups] = useState([]); + const [dbStats, setDbStats] = useState({ backup_count: 0, total_size_bytes: 0 }); + const [dbSettings, setDbSettings] = useState({ retention_count: 10, schedule_hour: 3, schedule_freq_days: 1 }); + const [isBackingUp, setIsBackingUp] = useState(false); + useEffect(() => { loadData(); }, []); @@ -59,14 +69,20 @@ export default function AdminPage() { const loadData = async () => { setLoading(true); try { - const [u, c, l] = await Promise.all([ + const [u, c, l, b, s, st] = await Promise.all([ inventoryApi.getUsers(), inventoryApi.getCategories(), - inventoryApi.getLdapConfig() + inventoryApi.getLdapConfig(), + inventoryApi.getDbBackups(), + inventoryApi.getDbStats(), + inventoryApi.getDbSettings() ]); setUsers(u); setCategories(c); if (l && l.server_uri) setLdapConfig(l); + setBackups(b); + setDbStats(s); + setDbSettings(st); } catch (err) { console.error(err); toast.error("Failed to load admin data"); @@ -161,44 +177,119 @@ export default function AdminPage() { window.location.href = '/login'; }; + const handleCreateBackup = async () => { + setIsBackingUp(true); + try { + await inventoryApi.triggerBackup(); + toast.success("Snapshot created successfully"); + loadData(); + } catch (err) { + toast.error("Backup failed"); + } finally { + setIsBackingUp(false); + } + }; + + const handleRestore = async (filename: string) => { + if (!confirm(`DANGEROUS: Restore database from ${filename}? Current data will be replaced. A rollback snapshot will be created automatically.`)) return; + + const loadingToast = toast.loading("Restoring database..."); + try { + await inventoryApi.restoreDatabase(filename); + toast.success("Database restored! Reloading system...", { id: loadingToast }); + setTimeout(() => window.location.reload(), 2000); + } catch (err) { + toast.error("Restore failed", { id: loadingToast }); + } + }; + + const handleUpdateDbSettings = async (newSettings: any) => { + try { + await inventoryApi.updateDbSettings(newSettings); + toast.success("System policy updated"); + setDbSettings(newSettings); + } catch (err) { + toast.error("Failed to update settings"); + } + }; + + const handleUpdateLdap = async () => { + setLoading(true); + try { + await inventoryApi.updateLdapConfig(ldapConfig); + toast.success("Enterprise configuration updated"); + } catch (err) { + toast.error("Failed to update LDAP config"); + } finally { + setLoading(false); + } + }; + + const handleTestLdap = async () => { + setTestingLdap(true); + try { + const res = await inventoryApi.testLdapConnection(ldapConfig); + if (res.status === 'success') { + toast.success(res.message || "LDAP Connection Successful!"); + } else { + toast.error(`LDAP Error: ${res.message || "Unknown error"}`); + } + } catch (err: any) { + toast.error(`Connection failed: ${err.response?.data?.detail || err.message}`); + } finally { + setTestingLdap(false); + } + }; + + const formatSize = (bytes: number) => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; + }; + return ( -
-
-
- +
+
+
+
-

System Admin

+

System Admin

Enterprise Control Center

{/* User Management Section */} -
+
-
- -

User Accounts

+
+
+ +
+
+

User Accounts

+

Manage local and network synchronized identities

+
-
+
{/* Local Users Group */}
-
-
-
-

Local Users

-
+
+
+

Local Users

-
+
{loading ? (
Loading...
) : ( @@ -207,33 +298,29 @@ export default function AdminPage() {
{u.role === 'admin' ? : }
-
-

{u.username}

- {u.role} -
+

{u.username}

+ {u.role}
-
+
{u.username !== 'Admin' && (
+ {/* Enterprise Integration (LDAP) Section */} +
+
+
+
+ +
+
+

Enterprise Integration

+

Configure directory services and single sign-on synchronization

+
+
+
+ Offline Only + + LDAP Active +
+
+ +
+
+
+
+ +
+ + setLdapConfig({ ...ldapConfig, server_uri: e.target.value })} + className="w-full bg-slate-950 border border-slate-800 rounded-2xl py-3 pl-10 pr-4 text-sm text-white outline-none focus:border-indigo-500/30 transition-all font-mono" + /> +
+
+
+ + setLdapConfig({ ...ldapConfig, base_dn: e.target.value })} + className="w-full bg-slate-950 border border-slate-800 rounded-2xl py-3 px-4 text-sm text-white outline-none focus:border-indigo-500/30 transition-all font-mono" + /> +
+
+ +
+ + setLdapConfig({ ...ldapConfig, user_template: e.target.value })} + className="w-full bg-slate-950 border border-slate-800 rounded-2xl py-3 px-4 text-sm text-white outline-none focus:border-indigo-500/30 transition-all font-mono" + /> +
+ +
+
+ + setLdapConfig({ ...ldapConfig, groups_dn: e.target.value })} + className="w-full bg-slate-950 border border-slate-800 rounded-2xl py-3 px-4 text-sm text-white outline-none focus:border-indigo-500/30 transition-all font-mono" + /> +
+
+ + setLdapConfig({ ...ldapConfig, required_group: e.target.value })} + className="w-full bg-slate-950 border border-slate-800 rounded-2xl py-3 px-4 text-sm text-white outline-none focus:border-indigo-500/30 transition-all font-mono" + /> +
+
+
+ +
+
+
+ +

Synchronization Shield

+
+

+ Enabling LDAP will allow users from your network domain to log in using their enterprise credentials. + Local account passwords will still function as a fail-safe backup. +

+
+
+
+ LDAPS / TLS +
+ +
+
+ +
+ + +
+
+
+
+ {/* Category Management Section */} -
+
-
- -

Category Groups

+
+
+ +
+
+

Category Groups

+

Classify inventory items into logical clusters

+
-
+
{loading ? (
Loading Categories...
) : ( @@ -311,26 +538,24 @@ export default function AdminPage() {

{cat.name}

-

+

{cat.description || 'General Purpose Group'}

-
+
- {/* System Settings Section */} -
-
-
- -
-
-

System Integrity

-

- Hybrid storage model active. Real-time synchronization between local memory and cloud-hosted SQLite. -

-
-
-
- Storage Online -
-
-
- - {/* Enterprise LDAP Integration */} -
-
- -
- -
-
-
- -
-
-

Enterprise Integration

-

LLDAP / Active Directory Connectivity

-
+ {/* System Integrity (Compacted and left-aligned) */} +
+
+
+
- -
- LDAP Status - +
+

System Integrity

+

+ Hybrid storage model active. Real-time synchronization between local memory and cloud-hosted SQLite instance. +

- -
-
-
- -
-
- -
- setLdapConfig({ ...ldapConfig, server_uri: e.target.value })} - placeholder="ldap://192.168.84.107:3890" - className="w-full bg-slate-950 border border-slate-800 focus:border-indigo-500/50 rounded-2xl py-4 pl-12 pr-5 text-white outline-none transition-all font-mono text-sm" - /> -
-
- -
- - setLdapConfig({ ...ldapConfig, base_dn: e.target.value })} - placeholder="dc=example,dc=com" - className="w-full bg-slate-950 border border-slate-800 focus:border-indigo-500/50 rounded-2xl py-4 px-5 text-white outline-none transition-all font-mono text-sm" - /> -
- -
- - setLdapConfig({ ...ldapConfig, user_template: e.target.value })} - placeholder="uid={username},ou=people,dc=..." - className="w-full bg-slate-950 border border-slate-800 focus:border-indigo-500/50 rounded-2xl py-4 px-5 text-white outline-none transition-all font-mono text-sm" - /> +
+
+ Storage Status +
+
+ Online
- -
-
-
- - -
- -
- {(ldapConfig.role_mappings || []).map((mapping: any, idx: number) => ( -
- { - const newMappings = [...ldapConfig.role_mappings]; - newMappings[idx].group = e.target.value; - setLdapConfig({ ...ldapConfig, role_mappings: newMappings }); - }} - placeholder="Group CN (e.g. admins)" - className="flex-1 bg-transparent text-xs font-mono text-white outline-none" - /> - - -
- ))} - {(ldapConfig.role_mappings || []).length === 0 && ( -
- No roles mapped -
- )} -
-
- -
-
-
- - Encrypted Connection (TLS) -
- -
- -
- - - -
-
- -
- -

- LLDAP usually serves LDAP on port 3890. Ensure your firewall allows traffic on this port between the inventory server and 192.168.84.107. -

+
+ Local Archives +
+ {dbStats.backup_count} Files + {formatSize(dbStats.total_size_bytes)}
+ {/* Database & Continuity Card */} +
+
+
+
+ +
+
+

Database & Continuity

+

Snapshot management and disaster recovery tools

+
+
+ +
+ +
+ {/* Scheduling Configuration */} +
+
+
+ + Retention & Automation +
+ +
+
+ + handleUpdateDbSettings({ ...dbSettings, retention_count: parseInt(e.target.value) || 1 })} + className="w-full bg-slate-900 border border-slate-800 rounded-xl py-2 px-4 text-sm text-white outline-none focus:border-amber-500/30 transition-all font-mono" + /> +
+ +
+
+ + +
+
+ + +
+
+
+ +
+

+ Automated backups are stored in /data/backups. Restoring data will overwrite the current live primary database. +

+
+
+
+ + {/* Backup History List */} +
+
+
+ Available Snapshots + Sorted by newest +
+
+ {backups.length === 0 ? ( +
No backups available on disk
+ ) : ( + backups.map((b) => ( +
+
+
+ +
+
+

{b.filename}

+
+ {new Date(b.created_at).toLocaleString()} + {formatSize(b.size_bytes)} +
+
+
+ +
+ )) + )} +
+
+
+
+
+ + {/* Edit User Modal */} {editingUser && (
diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 011fecdf..7de92786 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -5,19 +5,39 @@ @import "bootstrap-icons/font/bootstrap-icons.css"; :root { - --background: #ffffff; - --foreground: #171717; -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } + /* slate-950 forced as default to prevent white flash */ + --background: #020617; + --foreground: #f1f5f9; } body { color: var(--foreground); - background: var(--background); - font-family: Arial, Helvetica, sans-serif; + background-color: var(--background); + font-family: inherit; /* Use Next.js font if defined, or system default */ +} + +/* Custom Scrollbar Styling */ +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: #020617; /* slate-950 */ +} + +::-webkit-scrollbar-thumb { + background: #1e293b; /* slate-800 */ + border-radius: 10px; + border: 2px solid #020617; /* adds padding effect */ +} + +::-webkit-scrollbar-thumb:hover { + background: #334155; /* slate-700 */ +} + +/* Support for Firefox (limited) */ +* { + scrollbar-width: thin; + scrollbar-color: #1e293b #020617; } diff --git a/frontend/app/inventory/page.tsx b/frontend/app/inventory/page.tsx index d9624f13..84124f6c 100644 --- a/frontend/app/inventory/page.tsx +++ b/frontend/app/inventory/page.tsx @@ -196,35 +196,33 @@ export default function InventoryPage() { return ( -
+
{existingTypes.map(t => -
-

- - Inventory Catalog -

-

Detailed view of all stock items by category

+
+
+ +
+
+

Inventory Catalog

+

Enterprise Stock Overview

+
-
+
{/* Stats Dashboard */}
-
-
- -
-

Categories

-

{stats?.total_categories || categories.length}

+
+ +

Categories

+

{stats?.total_categories || categories.length}

-
-
- -
-

Item Types

-

{stats?.total_items || inventory.length}

+
+ +

Item Types

+

{stats?.total_items || inventory.length}

@@ -256,7 +254,7 @@ export default function InventoryPage() {

{cat}

-

+

{inventory.filter(i => i.category === cat).length} Item types in stock

diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index f4cb9eb0..fded33b2 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -25,7 +25,7 @@ export default function RootLayout({ - + {children} diff --git a/frontend/app/logs/page.tsx b/frontend/app/logs/page.tsx index 4dcec538..43b907b7 100644 --- a/frontend/app/logs/page.tsx +++ b/frontend/app/logs/page.tsx @@ -4,7 +4,7 @@ import { useState, useEffect } from 'react'; import { db, Item } from '@/lib/db'; import { inventoryApi } from '@/lib/api'; import PageShell from '@/components/PageShell'; -import { History, X, Search, Filter } from 'lucide-react'; +import { History, X, Search, Filter, Activity, ArrowDownCircle, ArrowUpCircle, User } from 'lucide-react'; import { cn } from '@/lib/utils'; import { fetchAndCacheItems } from '@/lib/sync'; @@ -61,16 +61,16 @@ export default function LogsPage() { return ( -
+
-
+
-

Audit Dashboard

-

Real-time Intervention Tracking

+

Audit Dashboard

+

Real-time Intervention Tracking

@@ -86,21 +86,25 @@ export default function LogsPage() { {/* Stats Grid */}
-
-

Total Events

-

{totalCount}

+
+ +

Total Events

+

{totalCount}

-
-

Flow In

-

{inCount}

+
+ +

Check in

+

{inCount}

-
-

Flow Out

-

{outCount}

+
+ +

Check out

+

{outCount}

-
-

Top Operator

-

{mostActiveUser}

+
+ +

Top Operator

+

{mostActiveUser}

@@ -117,18 +121,24 @@ export default function LogsPage() {
- {['ALL', 'CHECK_IN', 'CHECK_OUT', 'TRASH', 'CREATE'].map(action => ( + {[ + { label: 'All', value: 'ALL' }, + { label: 'Check in', value: 'CHECK_IN' }, + { label: 'Check out', value: 'CHECK_OUT' }, + { label: 'Trash', value: 'TRASH' }, + { label: 'Create', value: 'CREATE' } + ].map(action => ( ))}
@@ -139,7 +149,7 @@ export default function LogsPage() { {loading ? (
-

Securing Audit Stream...

+

Securing Audit Stream...

) : filteredLogs.length === 0 ? (
@@ -157,49 +167,45 @@ export default function LogsPage() { ))} diff --git a/frontend/components/BottomNav.tsx b/frontend/components/BottomNav.tsx index cdfcd677..c61f8c8b 100644 --- a/frontend/components/BottomNav.tsx +++ b/frontend/components/BottomNav.tsx @@ -68,10 +68,12 @@ export default function BottomNav({ {/* Logout */} )} @@ -310,7 +310,7 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr <>
- Label Scanning + Label Scanning {countdown === 0 ? "Scanning..." : `Next scan in ${countdown}s`} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 71ae1b57..46e43c81 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -164,5 +164,31 @@ export const inventoryApi = { deleteCategory: async (id: number) => { const res = await axiosInstance.delete(`/categories/${id}`); return res.data; + }, + + // Database Management + getDbBackups: async () => { + const res = await axiosInstance.get('/admin/db/backups'); + return res.data; + }, + getDbStats: async () => { + const res = await axiosInstance.get('/admin/db/stats'); + return res.data; + }, + triggerBackup: async () => { + const res = await axiosInstance.post('/admin/db/backup'); + return res.data; + }, + restoreDatabase: async (filename: string) => { + const res = await axiosInstance.post('/admin/db/restore', { filename, confirm: true }); + return res.data; + }, + getDbSettings: async () => { + const res = await axiosInstance.get('/admin/db/settings'); + return res.data; + }, + updateDbSettings: async (settings: any) => { + const res = await axiosInstance.patch('/admin/db/settings', settings); + return res.data; } }; diff --git a/start_server.sh b/start_server.sh index 633604ce..ca6e6de9 100755 --- a/start_server.sh +++ b/start_server.sh @@ -6,6 +6,9 @@ FRONTEND_PORT=3001 BACKEND_SSL_PORT=3002 FRONTEND_SSL_PORT=3003 +# Add common Mac paths for npm/node +export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" + echo "🚀 Starting TFM aInventory Stack with Dual Proxy..." # 1. Kill potentially hanging processes