Compare commits
44 Commits
v1.13.1
...
a6d78c4083
| Author | SHA1 | Date | |
|---|---|---|---|
| a6d78c4083 | |||
| 6e88ea9107 | |||
| b4df7201f5 | |||
| b0ac7426b7 | |||
| 941761ad83 | |||
| 20ace033a2 | |||
| c21ed699ec | |||
| f77582fdcd | |||
| 34ae803c7a | |||
| 654becfd86 | |||
| dcc3f692f3 | |||
| fb2947cfd0 | |||
| 1d22f1e85e | |||
| b87b1eceae | |||
| 5ed8c6c7db | |||
| f2fb81024e | |||
| 76f0e83a65 | |||
| 14ffecafdd | |||
| c85a7b78a6 | |||
| 6f9716c32b | |||
| 2ce0f84cd3 | |||
| 96f4a5d228 | |||
| 827dfcdf2f | |||
| e770938de8 | |||
| 9afe335384 | |||
| c14a28b093 | |||
| 593c3ba5e6 | |||
| 568bccb37e | |||
| e5a24df13d | |||
| 70523be677 | |||
| 6f34fdc782 | |||
| a5a460e765 | |||
| cc6c70a467 | |||
| 5978917494 | |||
| 33805cb79c | |||
| 978c6b8116 | |||
| 6be7318205 | |||
| 88e5d66f36 | |||
| 06718d3beb | |||
| 778d671a5f | |||
| 2b96d22eed | |||
| a6cb7e7bc7 | |||
| 73dde9b40d | |||
| fbab38b212 |
3
.standalone.pid
Normal file
3
.standalone.pid
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
836417
|
||||||
|
836418
|
||||||
|
836432
|
||||||
@@ -17,7 +17,15 @@ https://:8919 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Next.js HMR Support (WebSocket)
|
# Next.js HMR Support (WebSocket)
|
||||||
header_up X-Forwarded-For {remote_host}
|
handle /_next/webpack-hmr {
|
||||||
|
reverse_proxy http://localhost:8917 {
|
||||||
|
header_up Upgrade {>Upgrade}
|
||||||
|
header_up Connection {>Connection}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reverse_proxy http://localhost:8917 {
|
||||||
|
header_up X-Forwarded-For {remote_host}
|
||||||
header_up X-Forwarded-Proto https
|
header_up X-Forwarded-Proto https
|
||||||
header_up X-Forwarded-Host {host}
|
header_up X-Forwarded-Host {host}
|
||||||
}
|
}
|
||||||
@@ -37,7 +45,7 @@ https://:8918 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
reverse_proxy http://localhost:8916 {
|
reverse_proxy http://localhost:8916 {
|
||||||
header_up X-Forwarded-For {remote_host}
|
header_up X-Forwarded-For {remote_host}
|
||||||
header_up X-Forwarded-Proto https
|
header_up X-Forwarded-Proto https
|
||||||
header_up X-Forwarded-Host {host}
|
header_up X-Forwarded-Host {host}
|
||||||
}
|
}
|
||||||
|
|||||||
228
DESIGN-TYPOGRAPHY-HIERARCHY.md
Normal file
228
DESIGN-TYPOGRAPHY-HIERARCHY.md
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
# Design: Typography & Visual Hierarchy Overhaul
|
||||||
|
|
||||||
|
Generated by /gstack-office-hours on 2026-04-19
|
||||||
|
Branch: dev
|
||||||
|
Repo: tfm_ainventory
|
||||||
|
Status: DRAFT
|
||||||
|
Mode: Builder (Intrapreneurship)
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
The current UI has weak visual hierarchy on tablets and larger screens. While fonts are technically readable, they don't guide the eye — secondary info competes with primary info for attention. Users are forced to hunt for the information they need rather than seeing it immediately.
|
||||||
|
|
||||||
|
**Example:** Inventory item name (what you're looking for) is `text-base md:text-lg` (18px). The category label next to it is almost the same size. Which is more important? The UI doesn't say.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
**Typography scale (Tailwind defaults):**
|
||||||
|
- Labels/metadata: `text-xs` to `text-base` (12–16px)
|
||||||
|
- Headings: `text-lg` to `text-xl` (18–20px)
|
||||||
|
- Values/highlights: `text-2xl` to `text-3xl` (24–30px)
|
||||||
|
- Page titles: `text-3xl` to `text-4xl` (30–36px)
|
||||||
|
|
||||||
|
**Problem:** This scale flattens on tablets. A 18px label looks like a headline. A 30px value looks like a label. The hierarchy collapses.
|
||||||
|
|
||||||
|
## Premises
|
||||||
|
|
||||||
|
1. **Font size alone doesn't create hierarchy.** Weight, color, spacing, and contrast matter more than raw pixels.
|
||||||
|
2. **Tablet is a first-class device.** The app is used on iPads in warehouses. Design for that, not just mobile.
|
||||||
|
3. **Visual hierarchy = faster task completion.** If the user sees the item quantity immediately, they spend 0.5 seconds scanning. If they hunt, it's 3 seconds per item. Over 100 items, that's 4 minutes lost per session.
|
||||||
|
4. **We don't need bigger type everywhere.** Secondary info can stay small IF it's visually subordinate (lighter weight, muted color, less prominence).
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- What's the primary use case breakdown? Mostly mobile in the field, mostly tablet in warehouse, or mixed 50/50?
|
||||||
|
- Do users ever need to scan and read fine details simultaneously (e.g., zoom control labels)?
|
||||||
|
- Is there a standard tablet size we're optimizing for (iPad 10.2", iPad Pro 12.9", or both)?
|
||||||
|
|
||||||
|
## Approaches Considered
|
||||||
|
|
||||||
|
### Approach A: Scale Everything Proportionally (Simplest)
|
||||||
|
|
||||||
|
**Summary:** Increase base font sizes across all Tailwind scales uniformly. `text-base` → `text-lg`, `text-lg` → `text-xl`, etc. On tablets, everything gets bigger, hierarchy stays the same.
|
||||||
|
|
||||||
|
**Effort:** S (one config change to tailwind.config.ts, maybe 15 min)
|
||||||
|
|
||||||
|
**Risk:** Low
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- One-line fix in Tailwind config
|
||||||
|
- Consistent everywhere
|
||||||
|
- Easiest to test and verify
|
||||||
|
- No refactoring needed
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Doesn't actually improve hierarchy — just makes things bigger
|
||||||
|
- Wasteful on mobile (text gets huge, less content per screen)
|
||||||
|
- Doesn't address the real problem: weak contrast between important and unimportant info
|
||||||
|
- Secondary info still competes for attention, just in a bigger font
|
||||||
|
|
||||||
|
**Reuses:** Tailwind's built-in scale
|
||||||
|
|
||||||
|
**Implementation:** Extend `fontSize` in tailwind.config.ts:
|
||||||
|
```javascript
|
||||||
|
extend: {
|
||||||
|
fontSize: {
|
||||||
|
'xs': '14px', // was 12px
|
||||||
|
'sm': '15px', // was 14px
|
||||||
|
'base': '18px', // was 16px
|
||||||
|
'lg': '21px', // was 18px
|
||||||
|
// ... etc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Approach B: Responsive Scale + Weight-Based Hierarchy (Recommended)
|
||||||
|
|
||||||
|
**Summary:** Keep mobile readable, boost tablet sizes moderately. More importantly, use **font weight** (not just size) to create hierarchy. Primary info is bold/heavy; secondary is regular/light. Add color emphasis (primary color for key values).
|
||||||
|
|
||||||
|
**Effort:** M (audit components for hierarchy, add weight/color rules, test on device, ~2 hours)
|
||||||
|
|
||||||
|
**Risk:** Medium (changes visual feel, needs design review)
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Mobile stays readable (no bloat)
|
||||||
|
- Tablets get 20-30% bigger type
|
||||||
|
- Weight creates REAL hierarchy instantly (user's eye goes to bold text)
|
||||||
|
- Pairs with color (key values in primary color, metadata in muted gray)
|
||||||
|
- Feels intentional, not lazy
|
||||||
|
- Works across all screen sizes
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Requires auditing every component (20+ files)
|
||||||
|
- Needs design review to ensure consistency
|
||||||
|
- More moving parts to get right
|
||||||
|
- Testing on actual tablets essential
|
||||||
|
|
||||||
|
**Reuses:** Tailwind's weight classes, existing color system
|
||||||
|
|
||||||
|
**Implementation Pattern:**
|
||||||
|
```jsx
|
||||||
|
// OLD: No hierarchy
|
||||||
|
<span className="text-lg text-secondary">Item Name</span>
|
||||||
|
<span className="text-lg text-muted">Category</span>
|
||||||
|
|
||||||
|
// NEW: Clear hierarchy
|
||||||
|
<span className="text-lg md:text-xl font-bold text-white">Item Name</span>
|
||||||
|
<span className="text-sm md:text-base font-normal text-muted">Category</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tablet-specific rules:** Add responsive weights:
|
||||||
|
```javascript
|
||||||
|
extend: {
|
||||||
|
fontSize: {
|
||||||
|
'sm': ['14px', { lineHeight: '1.5' }],
|
||||||
|
'base': ['16px', { lineHeight: '1.6' }],
|
||||||
|
'lg': ['18px', { lineHeight: '1.6' }],
|
||||||
|
'xl': ['20px', { lineHeight: '1.5' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Approach C: Fluid Typography + Dynamic Scaling (Future-proof)
|
||||||
|
|
||||||
|
**Summary:** Use CSS custom properties (variables) to scale type fluidly from mobile → tablet → desktop. Type size increases as viewport width increases, without discrete breakpoints. Add hierarchy through weight, spacing, and contrast.
|
||||||
|
|
||||||
|
**Effort:** L (requires CSS architecture change, build TypeScript scale generator, test thoroughly, ~4 hours)
|
||||||
|
|
||||||
|
**Risk:** High (new tooling, requires testing on 3+ device sizes)
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Scales beautifully on ALL viewport sizes (not just mobile/md/lg breakpoints)
|
||||||
|
- Future-proof (works on foldables, 5" phones, 27" displays)
|
||||||
|
- Hierarchy through weight + color, not just size
|
||||||
|
- Professional feel (type gets more generous as screen gets bigger)
|
||||||
|
- One source of truth (single scale definition)
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Adds build-time complexity (need a script to generate scales)
|
||||||
|
- CSS custom properties have limited browser support (but fine for modern browsers)
|
||||||
|
- More moving parts, harder to debug
|
||||||
|
- Needs comprehensive testing
|
||||||
|
- Overkill if app is only mobile + tablet
|
||||||
|
|
||||||
|
**Reuses:** Tailwind, but adds custom CSS layer
|
||||||
|
|
||||||
|
**Implementation idea:**
|
||||||
|
```css
|
||||||
|
/* Define fluid scale as CSS variables */
|
||||||
|
:root {
|
||||||
|
/* At 375px (mobile), base = 16px. At 1440px (desktop), base = 18px. */
|
||||||
|
--font-base: clamp(16px, 2.5vw, 18px);
|
||||||
|
--font-lg: clamp(18px, 3vw, 20px);
|
||||||
|
--font-xl: clamp(20px, 3.5vw, 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Use in components */
|
||||||
|
.text-base { font-size: var(--font-base); }
|
||||||
|
.text-lg { font-size: var(--font-lg); }
|
||||||
|
.text-xl { font-size: var(--font-xl); }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Approach: B (Responsive Scale + Weight-Based Hierarchy)
|
||||||
|
|
||||||
|
**Why:**
|
||||||
|
- Solves the real problem (hierarchy, not just size)
|
||||||
|
- Effort-to-impact ratio is excellent
|
||||||
|
- Works across all devices without bloat
|
||||||
|
- Uses tools already in the design system
|
||||||
|
- Reviewable component-by-component (low risk of breaking things)
|
||||||
|
|
||||||
|
**Concrete next step:**
|
||||||
|
1. Audit InventoryTable, StatCard, LogsTable, Scanner controls for hierarchy
|
||||||
|
2. Add weight rules: primary data `font-bold`, metadata `font-normal`
|
||||||
|
3. Add color: primary values in `text-primary` or `text-white`, secondary in `text-muted`
|
||||||
|
4. Test on iPad (actual device or browser dev tools)
|
||||||
|
5. Review with user on actual tablet to verify readability
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- **Tablet test (10.2" iPad):** User can read item names, quantities, and key metadata without squinting at normal viewing distance (12-18 inches)
|
||||||
|
- **Hierarchy test:** In a list of 20 items, user can identify which is the primary info (name/quantity) in <1 second without searching
|
||||||
|
- **Mobile preservation:** iPhone 14 screen still shows useful content (no excessive whitespace)
|
||||||
|
- **Consistency:** Same typography rules applied across Scanner, Inventory, Admin, Logs pages
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Distribution Plan
|
||||||
|
|
||||||
|
No new dependencies or build system changes (unless Approach C chosen).
|
||||||
|
Changes ship in the existing `dev` branch → `master` on next release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Tailwind CSS (already in use)
|
||||||
|
- Design review from user on tablet device (essential)
|
||||||
|
- Testing across: iPhone 12+, iPad 10.2", iPad Pro 12.9" (ideally)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Assignment
|
||||||
|
|
||||||
|
**Before next office hours:**
|
||||||
|
1. Load the app on an actual tablet (iPad or comparable).
|
||||||
|
2. Open the Inventory page, Scanner page, and Admin Dashboard.
|
||||||
|
3. Note 3 specific screens or components where you think hierarchy is weakest (e.g., "StatCard mixes label and value with equal prominence").
|
||||||
|
4. Bring those examples to the next session — we'll design the weight/color fixes together with visual mockups.
|
||||||
|
|
||||||
|
This turns abstract ("fonts are small") into concrete ("here's where I get lost").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What I Noticed About How You Think
|
||||||
|
|
||||||
|
- You didn't say "make fonts bigger" — you said "better visual hierarchy." That's a designer's instinct, not an engineer's kneejerk response. You're thinking about information clarity, not pixel counts.
|
||||||
|
- You identified the problem on tablets specifically, not mobile. That's precise observation. Most people would have said "everywhere," but you know your actual use case.
|
||||||
|
- When asked what success looks like, you said hierarchy, not size. That shows you understand that **hierarchy IS the usability feature.** Big text that's all the same weight is just... big, hard-to-read text.
|
||||||
|
|
||||||
|
You're thinking like a product person. Keep that going.
|
||||||
@@ -28,9 +28,10 @@ A unified system for inventory management featuring web administration, offline
|
|||||||
|
|
||||||
### 2.3 Operations & Tooling
|
### 2.3 Operations & Tooling
|
||||||
- **PWA**: `next-pwa` (Service Workers + Manifest)
|
- **PWA**: `next-pwa` (Service Workers + Manifest)
|
||||||
- **HTTPS Proxy**: Caddy (Port 8909)
|
- **HTTPS Proxy**: Caddy (Ports 8918/8919)
|
||||||
- **Containerization**: Docker & Docker Compose
|
- **Containerization**: Docker & Docker Compose
|
||||||
- **Deployment**: `deploy.sh` (Docker) or `start_server.sh` (Standalone)
|
- **Deployment**: `deploy.py` (Docker) or `run_standalone.py` (Standalone)
|
||||||
|
- **Configuration**: Domain-specific YAML (`config/`) with `network.yaml` as SSOT.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -73,9 +74,9 @@ A unified system for inventory management featuring web administration, offline
|
|||||||
- **JWT**: Stateless tokens for API auth.
|
- **JWT**: Stateless tokens for API auth.
|
||||||
- **LDAP**: Primary source of truth for users in enterprise mode.
|
- **LDAP**: Primary source of truth for users in enterprise mode.
|
||||||
- **Password Caching**: Encrypted local cache for offline authentication.
|
- **Password Caching**: Encrypted local cache for offline authentication.
|
||||||
- **CORS**: Restricted origins in production via `inventory.env`.
|
- **CORS**: Restricted origins in production via `config/network.yaml` (SSOT).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Last Updated**: 2026-04-23
|
**Last Updated**: 2026-04-23
|
||||||
**Version**: 1.14.6
|
**Version**: 1.14.19
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.14.5",
|
"version": "1.14.7",
|
||||||
"lastUpdated": "2026-04-22",
|
"lastUpdated": "2026-04-23",
|
||||||
"phase": "Phase 3 Complete - Original Image Storage for Debug"
|
"phase": "Phase 7 Complete - Config Consolidation"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,15 +9,17 @@ from fastapi import Depends, HTTPException, status
|
|||||||
from fastapi.security import HTTPBearer
|
from fastapi.security import HTTPBearer
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from .config_loader import get_config
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
SECRET_KEY = os.environ.get("JWT_SECRET_KEY")
|
config = get_config()
|
||||||
if not SECRET_KEY:
|
SECRET_KEY = config.get("auth", {}).get("jwt_secret_key")
|
||||||
|
if not SECRET_KEY or SECRET_KEY == "change_me_in_production":
|
||||||
# Generate fallback key for dev (NOT FOR PRODUCTION)
|
# Generate fallback key for dev (NOT FOR PRODUCTION)
|
||||||
import secrets
|
import secrets
|
||||||
SECRET_KEY = secrets.token_urlsafe(32)
|
SECRET_KEY = secrets.token_urlsafe(32)
|
||||||
import sys
|
import sys
|
||||||
print(f"[WARNING] JWT_SECRET_KEY not set. Generated ephemeral key: {SECRET_KEY[:20]}...", file=sys.stderr)
|
print(f"[WARNING] JWT_SECRET_KEY not set or default used. Generated ephemeral key: {SECRET_KEY[:20]}...", file=sys.stderr)
|
||||||
|
|
||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 480 # 8 hours
|
ACCESS_TOKEN_EXPIRE_MINUTES = 480 # 8 hours
|
||||||
|
|||||||
@@ -59,8 +59,20 @@ def _apply_env_overrides(config):
|
|||||||
"CLAUDE_API_KEY": (("ai", "claude_api_key"), str),
|
"CLAUDE_API_KEY": (("ai", "claude_api_key"), str),
|
||||||
"BACKEND_AUTH_JWT_SECRET_KEY": (("auth", "jwt_secret_key"), str),
|
"BACKEND_AUTH_JWT_SECRET_KEY": (("auth", "jwt_secret_key"), str),
|
||||||
"JWT_SECRET_KEY": (("auth", "jwt_secret_key"), str),
|
"JWT_SECRET_KEY": (("auth", "jwt_secret_key"), str),
|
||||||
|
"BACKEND_AUTH_LDAP_ENABLED": (("auth", "ldap_enabled"), _to_bool),
|
||||||
|
"LDAP_ENABLED": (("auth", "ldap_enabled"), _to_bool),
|
||||||
"BACKEND_AUTH_LDAP_SERVER": (("auth", "ldap_server"), str),
|
"BACKEND_AUTH_LDAP_SERVER": (("auth", "ldap_server"), str),
|
||||||
|
"LDAP_SERVER": (("auth", "ldap_server"), str),
|
||||||
"BACKEND_AUTH_LDAP_BASE_DN": (("auth", "ldap_base_dn"), str),
|
"BACKEND_AUTH_LDAP_BASE_DN": (("auth", "ldap_base_dn"), str),
|
||||||
|
"LDAP_BASE_DN": (("auth", "ldap_base_dn"), str),
|
||||||
|
"BACKEND_AUTH_LDAP_USER_TEMPLATE": (("auth", "ldap_user_template"), str),
|
||||||
|
"LDAP_USER_TEMPLATE": (("auth", "ldap_user_template"), str),
|
||||||
|
"BACKEND_AUTH_LDAP_GROUPS_DN": (("auth", "ldap_groups_dn"), str),
|
||||||
|
"LDAP_GROUPS_DN": (("auth", "ldap_groups_dn"), str),
|
||||||
|
"BACKEND_AUTH_LDAP_USE_TLS": (("auth", "ldap_use_tls"), _to_bool),
|
||||||
|
"LDAP_USE_TLS": (("auth", "ldap_use_tls"), _to_bool),
|
||||||
|
"BACKEND_AUTH_LDAP_IGNORE_CERT": (("auth", "ldap_ignore_cert"), _to_bool),
|
||||||
|
"LDAP_IGNORE_CERT": (("auth", "ldap_ignore_cert"), _to_bool),
|
||||||
"BACKEND_AUTH_PASSWORD_CACHE_PATH": (("auth", "password_cache_path"), str),
|
"BACKEND_AUTH_PASSWORD_CACHE_PATH": (("auth", "password_cache_path"), str),
|
||||||
"BACKEND_LOGGING_LOG_LEVEL": (("logging", "log_level"), str),
|
"BACKEND_LOGGING_LOG_LEVEL": (("logging", "log_level"), str),
|
||||||
"LOG_LEVEL": (("logging", "log_level"), str),
|
"LOG_LEVEL": (("logging", "log_level"), str),
|
||||||
@@ -130,8 +142,17 @@ def load_config() -> dict:
|
|||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"jwt_secret_key": "change_me_in_production",
|
"jwt_secret_key": "change_me_in_production",
|
||||||
|
"ldap_enabled": False,
|
||||||
"ldap_server": "",
|
"ldap_server": "",
|
||||||
"ldap_base_dn": "",
|
"ldap_base_dn": "",
|
||||||
|
"ldap_user_template": "uid={username},ou=people,dc=ldap,dc=lan",
|
||||||
|
"ldap_groups_dn": "ou=groups",
|
||||||
|
"ldap_use_tls": True,
|
||||||
|
"ldap_ignore_cert": False,
|
||||||
|
"ldap_role_mappings": [
|
||||||
|
{"group": "inventory_admins", "role": "admin"},
|
||||||
|
{"group": "inventory_users", "role": "user"}
|
||||||
|
],
|
||||||
"password_cache_path": "data/.passwords"
|
"password_cache_path": "data/.passwords"
|
||||||
},
|
},
|
||||||
"logging": {
|
"logging": {
|
||||||
@@ -180,7 +201,38 @@ def load_config() -> dict:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(f"⚠️ Failed to load {secrets_yaml_path}: {e}")
|
log.warning(f"⚠️ Failed to load {secrets_yaml_path}: {e}")
|
||||||
|
|
||||||
# 4. Apply Environment Overrides (D-06)
|
# 4. Load network.yaml (SSOT for infrastructure)
|
||||||
|
network_yaml_path = os.path.join(config_dir, "network.yaml")
|
||||||
|
if os.path.exists(network_yaml_path):
|
||||||
|
try:
|
||||||
|
with open(network_yaml_path, 'r') as f:
|
||||||
|
network_data = yaml.safe_load(f) or {}
|
||||||
|
# Map infrastructure settings to application domain
|
||||||
|
infra = network_data.get("application", {})
|
||||||
|
if "server_ip" in infra:
|
||||||
|
config["application"]["server_ip"] = infra["server_ip"]
|
||||||
|
if "cors_origins" in infra:
|
||||||
|
# Append or override? Plan says establish as master.
|
||||||
|
# We merge with any existing ones.
|
||||||
|
existing = config["application"].get("cors_origins", "")
|
||||||
|
if existing:
|
||||||
|
config["application"]["cors_origins"] = f"{infra['cors_origins']},{existing}"
|
||||||
|
else:
|
||||||
|
config["application"]["cors_origins"] = infra["cors_origins"]
|
||||||
|
|
||||||
|
# Map ports to application domain for CORS logic
|
||||||
|
ports = network_data.get("ports", {})
|
||||||
|
for key, val in ports.items():
|
||||||
|
config["application"][key] = val
|
||||||
|
|
||||||
|
# Map SSL state
|
||||||
|
config["application"]["ssl_enabled"] = network_data.get("ssl", {}).get("ssl_enabled", False)
|
||||||
|
|
||||||
|
log.info(f"✅ Loaded network topology from {network_yaml_path}")
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"⚠️ Failed to load {network_yaml_path}: {e}")
|
||||||
|
|
||||||
|
# 5. Apply Environment Overrides (D-06)
|
||||||
_apply_env_overrides(config)
|
_apply_env_overrides(config)
|
||||||
|
|
||||||
_config = config
|
_config = config
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import yaml
|
import yaml
|
||||||
import logging
|
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")
|
log = logging.getLogger("ainventory")
|
||||||
|
|
||||||
@@ -52,8 +52,8 @@ class ConfigManager:
|
|||||||
log.info(f"✅ Updated {path} with new values.")
|
log.info(f"✅ Updated {path} with new values.")
|
||||||
|
|
||||||
# Reload the global config
|
# Reload the global config
|
||||||
load_config()
|
loader_load_config()
|
||||||
return get_config()
|
return loader_get_config()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"❌ Failed to write {path}: {e}")
|
log.error(f"❌ Failed to write {path}: {e}")
|
||||||
raise
|
raise
|
||||||
@@ -72,10 +72,78 @@ class ConfigManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
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
|
@staticmethod
|
||||||
def get_masked_key(key_name: str):
|
def get_masked_key(key_name: str):
|
||||||
"""Returns a masked version of a configuration value or env var."""
|
"""Returns a masked version of a configuration value or env var."""
|
||||||
config = get_config()
|
config = loader_get_config()
|
||||||
|
|
||||||
# Try to find in config first
|
# Try to find in config first
|
||||||
val = None
|
val = None
|
||||||
@@ -113,6 +181,10 @@ def update_config(updates: dict) -> dict:
|
|||||||
"""Update backend.yaml with new values and return updated config."""
|
"""Update backend.yaml with new values and return updated config."""
|
||||||
return ConfigManager.update_config(updates)
|
return ConfigManager.update_config(updates)
|
||||||
|
|
||||||
|
def get_config() -> dict:
|
||||||
|
"""Return the current global configuration."""
|
||||||
|
return loader_get_config()
|
||||||
|
|
||||||
def validate_config_file() -> bool:
|
def validate_config_file() -> bool:
|
||||||
"""Validate backend.yaml syntax and required fields."""
|
"""Validate backend.yaml syntax and required fields."""
|
||||||
return ConfigManager.validate_config_file()
|
return ConfigManager.validate_config_file()
|
||||||
|
|||||||
@@ -67,8 +67,11 @@ def get_ai_config(
|
|||||||
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
current_admin: auth.TokenData = Depends(auth.get_current_admin)
|
||||||
):
|
):
|
||||||
"""Check AI provider status and active provider."""
|
"""Check AI provider status and active provider."""
|
||||||
gemini_key = os.environ.get("GEMINI_API_KEY")
|
config = ConfigManager.get_config() # Alias for config_loader.get_config
|
||||||
claude_key = os.environ.get("CLAUDE_API_KEY")
|
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()
|
provider_setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_provider").first()
|
||||||
active_provider = provider_setting.value if provider_setting else "gemini"
|
active_provider = provider_setting.value if provider_setting else "gemini"
|
||||||
@@ -112,10 +115,14 @@ def update_ai_keys(
|
|||||||
if updates:
|
if updates:
|
||||||
ConfigManager.update_keys(updates)
|
ConfigManager.update_keys(updates)
|
||||||
|
|
||||||
|
# Re-load config to return accurate status
|
||||||
|
config = ConfigManager.get_config()
|
||||||
|
ai_cfg = config.get("ai", {})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"gemini_configured": bool(os.environ.get("GEMINI_API_KEY")),
|
"gemini_configured": bool(ai_cfg.get("gemini_api_key")),
|
||||||
"claude_configured": bool(os.environ.get("CLAUDE_API_KEY"))
|
"claude_configured": bool(ai_cfg.get("claude_api_key"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Supports CSV and Excel formats.
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
@@ -61,7 +61,7 @@ async def export_inventory_snapshot(
|
|||||||
# Log export action
|
# Log export action
|
||||||
from backend.models import AuditLog
|
from backend.models import AuditLog
|
||||||
audit_entry = AuditLog(
|
audit_entry = AuditLog(
|
||||||
user_id=admin_user.id,
|
user_id=admin_user.sub,
|
||||||
action="EXPORT_INVENTORY_SNAPSHOT",
|
action="EXPORT_INVENTORY_SNAPSHOT",
|
||||||
details=f"Exported in {format_type} format",
|
details=f"Exported in {format_type} format",
|
||||||
)
|
)
|
||||||
@@ -70,20 +70,16 @@ async def export_inventory_snapshot(
|
|||||||
|
|
||||||
# Return file response
|
# Return file response
|
||||||
if format_type == "csv":
|
if format_type == "csv":
|
||||||
# For CSV, use FileResponse with bytes
|
return StreamingResponse(
|
||||||
import io
|
|
||||||
return FileResponse(
|
|
||||||
io.BytesIO(content.encode("utf-8")),
|
io.BytesIO(content.encode("utf-8")),
|
||||||
media_type=media_type,
|
media_type=media_type,
|
||||||
filename=filename,
|
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# For Excel, content is already bytes
|
return StreamingResponse(
|
||||||
import io
|
|
||||||
return FileResponse(
|
|
||||||
io.BytesIO(content),
|
io.BytesIO(content),
|
||||||
media_type=media_type,
|
media_type=media_type,
|
||||||
filename=filename,
|
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -117,7 +113,7 @@ async def export_audit_trail(
|
|||||||
|
|
||||||
# Log export action
|
# Log export action
|
||||||
audit_entry = AuditLog(
|
audit_entry = AuditLog(
|
||||||
user_id=admin_user.id,
|
user_id=admin_user.sub,
|
||||||
action="EXPORT_AUDIT_TRAIL",
|
action="EXPORT_AUDIT_TRAIL",
|
||||||
details=f"Exported in {format_type} format",
|
details=f"Exported in {format_type} format",
|
||||||
)
|
)
|
||||||
@@ -126,22 +122,20 @@ async def export_audit_trail(
|
|||||||
|
|
||||||
# Return file response
|
# Return file response
|
||||||
if format_type == "csv":
|
if format_type == "csv":
|
||||||
import io
|
return StreamingResponse(
|
||||||
return FileResponse(
|
|
||||||
io.BytesIO(content.encode("utf-8")),
|
io.BytesIO(content.encode("utf-8")),
|
||||||
media_type=media_type,
|
media_type=media_type,
|
||||||
filename=filename,
|
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
import io
|
return StreamingResponse(
|
||||||
return FileResponse(
|
|
||||||
io.BytesIO(content),
|
io.BytesIO(content),
|
||||||
media_type=media_type,
|
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(
|
async def export_db(
|
||||||
format: str = Query("csv", description="Export format: csv or xlsx"),
|
format: str = Query("csv", description="Export format: csv or xlsx"),
|
||||||
type: str = Query("inventory", description="Export type: inventory, audit, or combined"),
|
type: str = Query("inventory", description="Export type: inventory, audit, or combined"),
|
||||||
@@ -217,7 +211,7 @@ async def export_db(
|
|||||||
|
|
||||||
# Log export action
|
# Log export action
|
||||||
audit_entry = AuditLog(
|
audit_entry = AuditLog(
|
||||||
user_id=admin_user.id,
|
user_id=admin_user.sub,
|
||||||
action="EXPORT_DB",
|
action="EXPORT_DB",
|
||||||
details=f"Exported {type} in {format_type} format",
|
details=f"Exported {type} in {format_type} format",
|
||||||
)
|
)
|
||||||
@@ -226,14 +220,14 @@ async def export_db(
|
|||||||
|
|
||||||
# Return file response
|
# Return file response
|
||||||
if format_type == "csv":
|
if format_type == "csv":
|
||||||
return FileResponse(
|
return StreamingResponse(
|
||||||
io.BytesIO(content.encode("utf-8")),
|
io.BytesIO(content.encode("utf-8")),
|
||||||
media_type=media_type,
|
media_type=media_type,
|
||||||
filename=filename,
|
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return FileResponse(
|
return StreamingResponse(
|
||||||
io.BytesIO(content),
|
io.BytesIO(content),
|
||||||
media_type=media_type,
|
media_type=media_type,
|
||||||
filename=filename,
|
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import os
|
|||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
from .. import models, schemas, database, auth
|
from .. import models, schemas, database, auth
|
||||||
|
from ..config_loader import get_config
|
||||||
from ..logger import log
|
from ..logger import log
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["auth"])
|
router = APIRouter(prefix="/users", tags=["auth"])
|
||||||
@@ -22,20 +23,18 @@ pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
|||||||
|
|
||||||
|
|
||||||
def get_ldap_config():
|
def get_ldap_config():
|
||||||
# Priority 1: Check in DATA_DIR (for Docker production)
|
config = get_config()
|
||||||
config_path = os.path.join(database.DATA_DIR, "config", "ldap_config.json")
|
auth_config = config.get("auth", {})
|
||||||
if os.path.exists(config_path):
|
return {
|
||||||
with open(config_path, "r") as f:
|
"ldap_enabled": auth_config.get("ldap_enabled", False),
|
||||||
return json.load(f)
|
"server_uri": auth_config.get("ldap_server"),
|
||||||
|
"base_dn": auth_config.get("ldap_base_dn"),
|
||||||
# Priority 2: Fallback to source-relative config (for local dev)
|
"user_template": auth_config.get("ldap_user_template"),
|
||||||
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
"groups_dn": auth_config.get("ldap_groups_dn"),
|
||||||
source_config_path = os.path.join(root_dir, "config", "ldap_config.json")
|
"use_tls": auth_config.get("ldap_use_tls", True),
|
||||||
if os.path.exists(source_config_path):
|
"ignore_cert": auth_config.get("ldap_ignore_cert", False),
|
||||||
with open(source_config_path, "r") as f:
|
"role_mappings": auth_config.get("ldap_role_mappings", [])
|
||||||
return json.load(f)
|
}
|
||||||
|
|
||||||
return {"ldap_enabled": False}
|
|
||||||
|
|
||||||
|
|
||||||
def authenticate_ldap(username, password):
|
def authenticate_ldap(username, password):
|
||||||
|
|||||||
BIN
backups/ainventory_2026-04-23_12-58-16.tar.gz
Normal file
BIN
backups/ainventory_2026-04-23_12-58-16.tar.gz
Normal file
Binary file not shown.
BIN
backups/ainventory_2026-04-23_12-58-31.tar.gz
Normal file
BIN
backups/ainventory_2026-04-23_12-58-31.tar.gz
Normal file
Binary file not shown.
BIN
backups/ainventory_2026-04-23_13-58-34.tar.gz
Normal file
BIN
backups/ainventory_2026-04-23_13-58-34.tar.gz
Normal file
Binary file not shown.
BIN
backups/ainventory_2026-04-23_14-04-08.tar.gz
Normal file
BIN
backups/ainventory_2026-04-23_14-04-08.tar.gz
Normal file
Binary file not shown.
BIN
backups/ainventory_2026-04-23_14-45-07.tar.gz
Normal file
BIN
backups/ainventory_2026-04-23_14-45-07.tar.gz
Normal file
Binary file not shown.
BIN
backups/ainventory_2026-04-23_14-51-52.tar.gz
Normal file
BIN
backups/ainventory_2026-04-23_14-51-52.tar.gz
Normal file
Binary file not shown.
BIN
backups/ainventory_2026-04-23_15-08-46.tar.gz
Normal file
BIN
backups/ainventory_2026-04-23_15-08-46.tar.gz
Normal file
Binary file not shown.
BIN
backups/ainventory_2026-04-23_15-15-00.tar.gz
Normal file
BIN
backups/ainventory_2026-04-23_15-15-00.tar.gz
Normal file
Binary file not shown.
BIN
backups/ainventory_2026-04-23_15-18-15.tar.gz
Normal file
BIN
backups/ainventory_2026-04-23_15-18-15.tar.gz
Normal file
Binary file not shown.
BIN
backups/ainventory_2026-04-23_15-19-55.tar.gz
Normal file
BIN
backups/ainventory_2026-04-23_15-19-55.tar.gz
Normal file
Binary file not shown.
BIN
backups/ainventory_2026-04-23_15-24-28.tar.gz
Normal file
BIN
backups/ainventory_2026-04-23_15-24-28.tar.gz
Normal file
Binary file not shown.
BIN
backups/ainventory_2026-04-23_15-27-27.tar.gz
Normal file
BIN
backups/ainventory_2026-04-23_15-27-27.tar.gz
Normal file
Binary file not shown.
41
config/Caddyfile.standalone
Normal file
41
config/Caddyfile.standalone
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# TFM aInventory - Caddy Patched IP Configuration
|
||||||
|
# Version 1.9.17 - The Dynamic Shield (Production Polish)
|
||||||
|
{
|
||||||
|
admin off
|
||||||
|
# Global TLS options for self-signed certificates
|
||||||
|
local_certs
|
||||||
|
skip_install_trust
|
||||||
|
|
||||||
|
# Configure on-demand TLS for private network IPs
|
||||||
|
on_demand_tls {
|
||||||
|
# Pointing to the backend root which returns 200 OK
|
||||||
|
# This allows Caddy to generate internal certs for any IP/domain.
|
||||||
|
ask http://backend:8000/
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dynamic SSL Proxy (Matches ANY IP or hostname)
|
||||||
|
https:// {
|
||||||
|
tls internal {
|
||||||
|
on_demand
|
||||||
|
}
|
||||||
|
|
||||||
|
reverse_proxy frontend:3000
|
||||||
|
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
X-XSS-Protection "1; mode=block"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Specific port listener for backend (8918 -> 444)
|
||||||
|
https://:444 {
|
||||||
|
tls internal {
|
||||||
|
on_demand
|
||||||
|
}
|
||||||
|
reverse_proxy backend:8000
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,14 +75,17 @@ Controls the Next.js frontend application behavior and PWA settings.
|
|||||||
| `pwa.app_name` | Display name of the PWA | `TFM aInventory` |
|
| `pwa.app_name` | Display name of the PWA | `TFM aInventory` |
|
||||||
|
|
||||||
### network.yaml
|
### network.yaml
|
||||||
Defines host-side port mappings and SSL/CORS policies.
|
**SINGLE SOURCE OF TRUTH (SSOT):** This is the master file for your server's network topology. All other domains (Backend CORS, Frontend API URL) are dynamically derived from these settings.
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
|
| `application.server_ip` | **MASTER IP** of your server | `localhost` |
|
||||||
|
| `application.cors_origins`| Extra allowed origins (Subnets/IPs) | - |
|
||||||
| `ports.backend_port` | Host port for backend | `8916` |
|
| `ports.backend_port` | Host port for backend | `8916` |
|
||||||
| `ports.frontend_port` | Host port for frontend | `8917` |
|
| `ports.frontend_port` | Host port for frontend | `8917` |
|
||||||
| `ssl.ssl_enabled` | Enable HTTPS via Caddy | `true` |
|
| `ssl.ssl_enabled` | Enable HTTPS via Caddy | `true` |
|
||||||
| `cors.allowed_origins` | List of allowed origins | `*` |
|
|
||||||
|
> **Note:** You only need to change the IP address in **this file**. The system will automatically update the frontend's API URL and the backend's CORS policies upon restart.
|
||||||
|
|
||||||
### docker.yaml
|
### docker.yaml
|
||||||
Resource limits and container orchestration settings.
|
Resource limits and container orchestration settings.
|
||||||
|
|||||||
@@ -35,31 +35,51 @@ ai:
|
|||||||
# Environment: BACKEND_AI_FALLBACK_PROVIDER
|
# Environment: BACKEND_AI_FALLBACK_PROVIDER
|
||||||
fallback_provider: "claude"
|
fallback_provider: "claude"
|
||||||
|
|
||||||
# Gemini API Key (obtain from https://aistudio.google.com/)
|
|
||||||
# Environment: BACKEND_AI_GEMINI_API_KEY or GEMINI_API_KEY
|
|
||||||
gemini_api_key: "your-gemini-api-key"
|
|
||||||
|
|
||||||
# Claude API Key (obtain from Anthropic Console)
|
|
||||||
# Environment: BACKEND_AI_CLAUDE_API_KEY or CLAUDE_API_KEY
|
|
||||||
claude_api_key: "your-claude-api-key"
|
|
||||||
|
|
||||||
# --- Authentication & LDAP ---
|
# --- Authentication & LDAP ---
|
||||||
auth:
|
auth:
|
||||||
# JWT Secret Key (min 32 chars)
|
# Enable LDAP Authentication?
|
||||||
# Generate with: openssl rand -hex 32
|
# Default: false
|
||||||
# Environment: BACKEND_AUTH_JWT_SECRET_KEY or JWT_SECRET_KEY
|
# Environment: BACKEND_AUTH_LDAP_ENABLED or LDAP_ENABLED
|
||||||
jwt_secret_key: "change_me_in_production"
|
ldap_enabled: false
|
||||||
|
|
||||||
# LDAP Server address (optional)
|
# LDAP Server address (optional)
|
||||||
# Example: "ldap://192.168.1.100"
|
# Example: "ldaps://192.168.1.100:636"
|
||||||
# Environment: BACKEND_AUTH_LDAP_SERVER
|
# Environment: BACKEND_AUTH_LDAP_SERVER or LDAP_SERVER
|
||||||
ldap_server: ""
|
ldap_server: ""
|
||||||
|
|
||||||
# LDAP Base DN for user search
|
# LDAP Base DN for user search
|
||||||
# Example: "dc=example,dc=com"
|
# Example: "dc=example,dc=com"
|
||||||
# Environment: BACKEND_AUTH_LDAP_BASE_DN
|
# Environment: BACKEND_AUTH_LDAP_BASE_DN or LDAP_BASE_DN
|
||||||
ldap_base_dn: ""
|
ldap_base_dn: ""
|
||||||
|
|
||||||
|
# LDAP User DN Template
|
||||||
|
# Example: "uid={username},ou=people,dc=example,dc=com"
|
||||||
|
# Environment: BACKEND_AUTH_LDAP_USER_TEMPLATE or LDAP_USER_TEMPLATE
|
||||||
|
ldap_user_template: "uid={username},ou=people,dc=ldap,dc=lan"
|
||||||
|
|
||||||
|
# LDAP Groups Base DN
|
||||||
|
# Example: "ou=groups,dc=example,dc=com"
|
||||||
|
# Environment: BACKEND_AUTH_LDAP_GROUPS_DN or LDAP_GROUPS_DN
|
||||||
|
ldap_groups_dn: "ou=groups"
|
||||||
|
|
||||||
|
# Use TLS for LDAP connection?
|
||||||
|
# Default: true
|
||||||
|
# Environment: BACKEND_AUTH_LDAP_USE_TLS or LDAP_USE_TLS
|
||||||
|
ldap_use_tls: true
|
||||||
|
|
||||||
|
# Ignore TLS certificate verification? (NOT FOR PRODUCTION)
|
||||||
|
# Default: false
|
||||||
|
# Environment: BACKEND_AUTH_LDAP_IGNORE_CERT or LDAP_IGNORE_CERT
|
||||||
|
ldap_ignore_cert: false
|
||||||
|
|
||||||
|
# LDAP Role Mappings (List of group-to-role mappings)
|
||||||
|
# assigned_role = None (if no match found)
|
||||||
|
ldap_role_mappings:
|
||||||
|
- group: "inventory_admins"
|
||||||
|
role: "admin"
|
||||||
|
- group: "inventory_users"
|
||||||
|
role: "user"
|
||||||
|
|
||||||
# Path to store hashed passwords for offline use
|
# Path to store hashed passwords for offline use
|
||||||
# Environment: BACKEND_AUTH_PASSWORD_CACHE_PATH
|
# Environment: BACKEND_AUTH_PASSWORD_CACHE_PATH
|
||||||
password_cache_path: "data/.passwords"
|
password_cache_path: "data/.passwords"
|
||||||
@@ -91,10 +111,6 @@ application:
|
|||||||
# Environment: BACKEND_APPLICATION_LOGS_DIR or LOGS_DIR
|
# Environment: BACKEND_APPLICATION_LOGS_DIR or LOGS_DIR
|
||||||
logs_dir: "./logs"
|
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 ---
|
# --- Feature Flags ---
|
||||||
features:
|
features:
|
||||||
# Enable AI image extraction and OCR?
|
# Enable AI image extraction and OCR?
|
||||||
|
|||||||
@@ -6,11 +6,9 @@
|
|||||||
|
|
||||||
# --- API Connection ---
|
# --- API Connection ---
|
||||||
api:
|
api:
|
||||||
# Base URL of the backend API
|
# Note: backend_url is dynamically injected by the launcher script
|
||||||
# Default: http://localhost:8916
|
# based on network.yaml settings to ensure SSOT integrity.
|
||||||
# Environment: FRONTEND_API_BACKEND_URL or BACKEND_URL
|
|
||||||
backend_url: "http://localhost:8916"
|
|
||||||
|
|
||||||
# API timeout in milliseconds
|
# API timeout in milliseconds
|
||||||
# Default: 30000 (30 seconds)
|
# Default: 30000 (30 seconds)
|
||||||
# Environment: FRONTEND_API_TIMEOUT_MS
|
# Environment: FRONTEND_API_TIMEOUT_MS
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"ldap_enabled": true,
|
|
||||||
"server_uri": "ldaps://192.168.84.107:6360",
|
|
||||||
"base_dn": "dc=ldap,dc=lan",
|
|
||||||
"user_template": "uid={username},ou=people,dc=ldap,dc=lan",
|
|
||||||
"groups_dn": "ou=groups",
|
|
||||||
"use_tls": true,
|
|
||||||
"role_mappings": [
|
|
||||||
{
|
|
||||||
"group": "inventory_admins",
|
|
||||||
"role": "admin"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"group": "inventory_users",
|
|
||||||
"role": "user"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ignore_cert": true
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"ldap_enabled": false,
|
|
||||||
"server_uri": "ldap://192.168.1.100:389",
|
|
||||||
"use_tls": false,
|
|
||||||
"ignore_cert": false,
|
|
||||||
"base_dn": "dc=example,dc=com",
|
|
||||||
"user_template": "uid={username},ou=users,dc=example,dc=com",
|
|
||||||
"role_mappings": [
|
|
||||||
{
|
|
||||||
"group": "cn=inventory_admins,ou=groups,dc=example,dc=com",
|
|
||||||
"role": "admin"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"group": "cn=inventory_users,ou=groups,dc=example,dc=com",
|
|
||||||
"role": "user"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -38,36 +38,18 @@ ssl:
|
|||||||
certificate_path: ""
|
certificate_path: ""
|
||||||
|
|
||||||
# Path to SSL private key
|
# Path to SSL private key
|
||||||
# Environment: NETWORK_SSL_KEY_PATH
|
# Path to SSL private key
|
||||||
key_path: ""
|
key_path: ""
|
||||||
|
|
||||||
# --- Proxy (Caddy) Configuration ---
|
# --- Master Infrastructure Settings ---
|
||||||
proxy:
|
# Establish this file as the SSOT for network topology.
|
||||||
# Caddy log level (debug|info|warn|error)
|
application:
|
||||||
# Default: info
|
# The IP address or hostname of the server (Used for CORS and Frontend API)
|
||||||
# Environment: NETWORK_PROXY_CADDY_LOG_LEVEL
|
# Default: localhost
|
||||||
caddy_log_level: "info"
|
# Environment: SERVER_IP
|
||||||
|
server_ip: "localhost"
|
||||||
|
|
||||||
# Maximum timeout for reading requests in seconds
|
# Comma-separated list of extra allowed CORS origins (IPs/FQDNs/Subnets)
|
||||||
# Default: 60
|
# Environment: EXTRA_ALLOWED_ORIGINS
|
||||||
# Environment: NETWORK_PROXY_READ_TIMEOUT_S
|
cors_origins: ""
|
||||||
proxy_read_timeout_s: 60
|
|
||||||
|
|
||||||
# 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"
|
|
||||||
|
|||||||
@@ -32,13 +32,21 @@ A unified inventory management system that eliminates manual data entry through
|
|||||||
- **Search & Filtering**: Advanced modal-based search.
|
- **Search & Filtering**: Advanced modal-based search.
|
||||||
- **Export/Reports**: CSV/Excel exports for compliance.
|
- **Export/Reports**: CSV/Excel exports for compliance.
|
||||||
|
|
||||||
### Phase 6: Deployment & Scale (CURRENT)
|
### Phase 6: Deployment & Scale (COMPLETED ✓)
|
||||||
- **Containerization**: Full Docker support with Caddy proxy.
|
- **Containerization**: Full Docker support with Caddy proxy.
|
||||||
- **Automation**: Single-command `deploy.sh`.
|
- **Automation**: Single-command `deploy.sh` (now `deploy.py`).
|
||||||
- **Cleanup**: Consolidate documentation, remove obsolete planning artifacts.
|
- **Cleanup**: Consolidate documentation, remove obsolete planning artifacts.
|
||||||
- **Performance**: Scale testing for 10K+ items and 5+ concurrent users.
|
- **Performance**: Scale testing for 10K+ items and 5+ concurrent users.
|
||||||
|
|
||||||
### Phase 7: Hardening & Release (PLANNED)
|
### Phase 7: Config Consolidation (COMPLETED ✓)
|
||||||
|
- **Centralization**: All config in `config/` folder (YAML format).
|
||||||
|
- **Automation**: Bash scripts converted to Python (`scripts/`).
|
||||||
|
- **SSOT Architecture**: `network.yaml` established as Master IP/Port authority.
|
||||||
|
- **Dynamic Injection**: Automatic API URL and CORS calculation from master config.
|
||||||
|
- **D-06 Load Order**: Env Vars > YAML > Defaults.
|
||||||
|
- **Deprecation**: `inventory.env` and legacy `.sh` scripts completely removed.
|
||||||
|
|
||||||
|
### Phase 8: Hardening & Release (PLANNED)
|
||||||
- Stability monitoring, final UX refinements, production-ready runbook.
|
- Stability monitoring, final UX refinements, production-ready runbook.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -2,68 +2,53 @@
|
|||||||
|
|
||||||
**Active AI:** Claude Haiku 4.5 (Claude Code)
|
**Active AI:** Claude Haiku 4.5 (Claude Code)
|
||||||
**Last Updated:** 2026-04-23
|
**Last Updated:** 2026-04-23
|
||||||
**Current Version:** v1.14.6
|
**Current Version:** v1.14.19
|
||||||
**Status**: 🟢 PHASE 7 PLANNED
|
**Status**: ✅ PHASE 7 COMPLETE | 🟢 READY FOR PHASE 8
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## SESSION 40 EXECUTION — Phase 7 Config Consolidation Planning
|
## SESSION 41 EXECUTION SUMMARY — Infrastructure & Export Fixes
|
||||||
|
|
||||||
### Work Completed This Session
|
### 1. Phase 7: Config Consolidation (COMPLETE)
|
||||||
|
- **Centralized YAML**: Migrated all configuration to `config/*.yaml`.
|
||||||
|
- **SSOT Architecture**: Established `network.yaml` as the Master Source of Truth for Server IP and Port management.
|
||||||
|
- **Dynamic Injection**: Frontend API URLs and Backend CORS are now dynamically calculated from master network settings.
|
||||||
|
- **Python Tooling**: All legacy `.sh` scripts converted to robust, secure Python scripts in `scripts/`.
|
||||||
|
- **Enhanced Launcher**: `run_standalone.py` now supports `start`, `stop`, `restart`, `status`, and Caddy SSL proxying.
|
||||||
|
|
||||||
**1. Phase 7 Planning (Config Consolidation):**
|
### 2. Admin & Security Improvements
|
||||||
- ✅ Executed `/gsd-plan-phase 7 --skip-research`
|
- **AI Key Detection**: Fixed backend logic to correctly report AI key status from YAML/Secrets.
|
||||||
- ✅ Replanned Phase 7 from scratch (existing 18-task .env plan was misaligned with CONTEXT decisions)
|
- **Secure Saving**: Implemented `ConfigManager.update_keys()` to route sensitive data correctly to `secrets.yaml`.
|
||||||
- ✅ Created 4 new comprehensive plans aligned with YAML + Python decisions:
|
- **Recursion Fix**: Resolved a critical infinite recursion loop in the configuration manager.
|
||||||
- **Plan 07-01**: Create config/ folder structure with YAML files + examples + documentation
|
|
||||||
- **Plan 07-02**: Refactor backend config_loader.py for YAML parsing + deprecate inventory.env
|
|
||||||
- **Plan 07-03**: Convert 4 bash deployment scripts to Python (deploy.py, run_standalone.py, install_service.py, export_prod.py)
|
|
||||||
- **Plan 07-04**: Update Docker/Compose, Dockerfile, .gitignore, documentation
|
|
||||||
- ✅ All plans verified and passed gsd-plan-checker (17 tasks, 31 files, all decisions covered)
|
|
||||||
|
|
||||||
**2. Phase 7 Decisions Implemented:**
|
### 3. Export System Overhaul
|
||||||
- ✅ **D-01**: YAML format standardization (backend.yaml, frontend.yaml, network.yaml, docker.yaml)
|
- **Data Integrity**: Fixed a routing collision that caused binary SQLite downloads instead of CSV data.
|
||||||
- ✅ **D-02**: Secrets management (config/secrets.yaml, git-ignored, .example files)
|
- **FastAPI Streaming**: Refactored exports to use `StreamingResponse` for memory efficiency.
|
||||||
- ✅ **D-03**: Example files tracked in git (*.yaml.example)
|
- **Premium UI**: Integrated the `ExportPanel` into the dark-mode aesthetic with standard premium button styles.
|
||||||
- ✅ **D-04**: Complete inventory.env deprecation (no fallback)
|
- **WebSocket Fix**: Improved Caddy configuration for Next.js HMR stability.
|
||||||
- ✅ **D-05**: Bash → Python script conversion for deployment
|
|
||||||
- ✅ **D-06**: Backend config load order: env vars > YAML > defaults
|
|
||||||
- ✅ **D-07**: Docker Compose updated for config/ structure
|
|
||||||
- ✅ **D-08**: Comprehensive documentation (DEPLOYMENT.md, README.md, config/README.md)
|
|
||||||
|
|
||||||
**3. Plan Structure:**
|
### 4. Cleanup & Preparation
|
||||||
- **Wave 1**: Foundation (config/ structure creation)
|
- ✅ Deleted all deprecated `.sh` and `.env` files.
|
||||||
- **Wave 2**: Backend refactoring + Python scripts (parallel execution)
|
- ✅ Rebuilt virtual environment (`venv/`) and verified dependencies (added `openpyxl`).
|
||||||
- **Wave 3**: Docker integration + final documentation (depends on Waves 1-2)
|
- ✅ Incremented system version to **v1.14.19**.
|
||||||
|
|
||||||
### Phase 7 Artifact Status
|
|
||||||
- **Plans**: `.planning/phases/07-config-consolidation/07-{01,02,03,04}-PLAN.md` ✓ Created
|
|
||||||
- **Context**: `.planning/phases/07-config-consolidation/07-CONTEXT.md` ✓ Exists (locked decisions)
|
|
||||||
- **Verification**: All 4 plans passed gsd-plan-checker ✓
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## NEXT STEPS
|
## NEXT STEPS (Phase 8: Hardening & Release)
|
||||||
|
|
||||||
1. **Execute Phase 7**:
|
1. **End-to-End Testing**:
|
||||||
```bash
|
- Verify data consistency in multi-page Excel exports.
|
||||||
/gsd-execute-phase 7
|
- Stress test the new Python-based deployment scripts.
|
||||||
```
|
|
||||||
- Wave 1: Create config/ folder with YAML files and examples
|
|
||||||
- Wave 2 (parallel): Refactor backend config loader + create Python deployment scripts
|
|
||||||
- Wave 3: Update Docker, documentation, and .gitignore
|
|
||||||
|
|
||||||
2. **After Phase 7 Completion**:
|
2. **UX Refinement**:
|
||||||
- All configuration consolidated in config/ folder (YAML format)
|
- Perform a final accessibility audit on the new Admin UI components.
|
||||||
- inventory.env deprecated and removed
|
- Ensure consistent loading states across all Admin panels.
|
||||||
- Deployment scripts converted to Python
|
|
||||||
- Docker and standalone deployments fully working with new structure
|
|
||||||
- Comprehensive documentation in place
|
|
||||||
|
|
||||||
3. **Future Phases**:
|
3. **Production Preparation**:
|
||||||
- Phase 8+ (as defined in ROADMAP)
|
- Finalize the production runbook.
|
||||||
- Consider advanced config features (hot-reload, KMS encryption, config versioning) for future phases
|
- Prepare for the v1.15.0 "Stable" release milestone.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
✓ Phase 7 planned and ready for execution.
|
✓ All tasks for this session are implemented, verified, and committed.
|
||||||
|
✓ Services are currently RUNNING in the background.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": "1.13.1",
|
"version": "1.14.20",
|
||||||
"last_build": "2026-04-23-1256",
|
"last_build": "2026-04-23-1536",
|
||||||
"codename": "PhotoUI",
|
"codename": "ConfigCore",
|
||||||
"commit": "d9dffdc3"
|
"commit": "b0ac7426"
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,7 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={admin.handleLogout}
|
onClick={admin.handleLogout}
|
||||||
className="ml-auto p-3 bg-surface border border-slate-800 text-muted hover:text-rose-500 rounded-2xl transition-all active:scale-95 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:ring-rose-500 focus-visible:outline-none"
|
className="ml-auto p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8 bg-surface border border-slate-800 text-muted hover:text-rose-500 rounded-2xl transition-all active:scale-95 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:ring-rose-500 focus-visible:outline-none"
|
||||||
title="Secure Logout"
|
title="Secure Logout"
|
||||||
aria-label="Logout from admin panel"
|
aria-label="Logout from admin panel"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -346,7 +346,7 @@ export default function InventoryPage() {
|
|||||||
|
|
||||||
{/* Stock Adjustment / Edit Item Overlay */}
|
{/* Stock Adjustment / Edit Item Overlay */}
|
||||||
{selectedItem && (
|
{selectedItem && (
|
||||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-background/80 animate-in fade-in duration-200">
|
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-background/80 animate-in fade-in duration-200">
|
||||||
<div className="w-full max-w-lg bg-surface border-x border-t sm:border border-slate-800 rounded-t-[2.5rem] sm:rounded-[2.5rem] shadow-2xl p-5 sm:p-8 overflow-hidden animate-in slide-in-from-bottom-10 duration-300">
|
<div className="w-full max-w-lg bg-surface border-x border-t sm:border border-slate-800 rounded-t-[2.5rem] sm:rounded-[2.5rem] shadow-2xl p-5 sm:p-8 overflow-hidden animate-in slide-in-from-bottom-10 duration-300">
|
||||||
<div className="flex justify-between items-center mb-3 md:mb-4">
|
<div className="flex justify-between items-center mb-3 md:mb-4">
|
||||||
<h3 className="text-xl font-normal tracking-tight flex items-center gap-2">
|
<h3 className="text-xl font-normal tracking-tight flex items-center gap-2">
|
||||||
@@ -364,7 +364,7 @@ export default function InventoryPage() {
|
|||||||
setEditedItem(selectedItem);
|
setEditedItem(selectedItem);
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
}}
|
}}
|
||||||
className="p-2 hover:bg-slate-800 rounded-full text-secondary"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 hover:bg-slate-800 rounded-full text-secondary"
|
||||||
>
|
>
|
||||||
<Edit2 size={20} />
|
<Edit2 size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -372,7 +372,7 @@ export default function InventoryPage() {
|
|||||||
{isEditing && (
|
{isEditing && (
|
||||||
<button
|
<button
|
||||||
onClick={handleDeleteItem}
|
onClick={handleDeleteItem}
|
||||||
className="p-2 hover:bg-red-500/20 rounded-full text-red-500"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 hover:bg-red-500/20 rounded-full text-red-500"
|
||||||
>
|
>
|
||||||
<Trash2 size={20} />
|
<Trash2 size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -383,7 +383,7 @@ export default function InventoryPage() {
|
|||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setAdjustQty(1);
|
setAdjustQty(1);
|
||||||
}}
|
}}
|
||||||
className="p-2 hover:bg-slate-800 rounded-full"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 hover:bg-slate-800 rounded-full"
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -486,7 +486,7 @@ export default function InventoryPage() {
|
|||||||
toast.success("Ready to scan Box label...");
|
toast.success("Ready to scan Box label...");
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute right-2 p-2 rounded-lg transition-all",
|
"absolute right-2 p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 rounded-lg transition-all",
|
||||||
fieldScanning?.active ? "bg-primary text-white animate-pulse" : "text-muted hover:bg-slate-800"
|
fieldScanning?.active ? "bg-primary text-white animate-pulse" : "text-muted hover:bg-slate-800"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -524,7 +524,7 @@ export default function InventoryPage() {
|
|||||||
key={t.id}
|
key={t.id}
|
||||||
onClick={() => setAdjustType(t.id as any)}
|
onClick={() => setAdjustType(t.id as any)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 flex flex-col items-center py-3 rounded-xl transition-all",
|
"flex-1 flex flex-col items-center py-3 lg:py-4 xl:py-5 rounded-xl transition-all",
|
||||||
adjustType === t.id ? "bg-slate-800 shadow-lg" : "text-muted"
|
adjustType === t.id ? "bg-slate-800 shadow-lg" : "text-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -552,11 +552,11 @@ export default function InventoryPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{adjustType === 'TRASH' && (
|
{adjustType === 'TRASH' && (
|
||||||
<div className="w-full bg-red-500/5 border border-red-500/20 p-4 rounded-2xl">
|
<div className="w-full bg-red-500/5 border border-red-500/20 p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 rounded-2xl">
|
||||||
<select
|
<select
|
||||||
value={trashReason}
|
value={trashReason}
|
||||||
onChange={(e) => setTrashReason(e.target.value)}
|
onChange={(e) => setTrashReason(e.target.value)}
|
||||||
className="w-full bg-background border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-foreground"
|
className="w-full bg-background border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl outline-none text-foreground"
|
||||||
>
|
>
|
||||||
<option>Damaged</option>
|
<option>Damaged</option>
|
||||||
<option>Expired</option>
|
<option>Expired</option>
|
||||||
@@ -608,7 +608,7 @@ export default function InventoryPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={catEditedName}
|
value={catEditedName}
|
||||||
onChange={e => setCatEditedName(e.target.value)}
|
onChange={e => setCatEditedName(e.target.value)}
|
||||||
className="w-full bg-background border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-secondary"
|
className="w-full bg-background border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl outline-none text-secondary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -616,7 +616,7 @@ export default function InventoryPage() {
|
|||||||
<textarea
|
<textarea
|
||||||
value={catEditedDesc}
|
value={catEditedDesc}
|
||||||
onChange={e => setCatEditedDesc(e.target.value)}
|
onChange={e => setCatEditedDesc(e.target.value)}
|
||||||
className="w-full bg-background border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-secondary h-24 resize-none"
|
className="w-full bg-background border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl outline-none text-secondary h-24 resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -641,7 +641,7 @@ export default function InventoryPage() {
|
|||||||
|
|
||||||
{/* Box Manager Modal */}
|
{/* Box Manager Modal */}
|
||||||
{showBoxManager && (
|
{showBoxManager && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/90 animate-in fade-in duration-300">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-background/90 animate-in fade-in duration-300">
|
||||||
<div className="w-full max-w-2xl bg-surface border border-slate-800 rounded-[2.5rem] shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
<div className="w-full max-w-2xl bg-surface border border-slate-800 rounded-[2.5rem] shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||||
<div className="p-4 md:p-6 pb-3 md:pb-4 flex flex-col gap-3 md:gap-4 shrink-0 bg-surface/70">
|
<div className="p-4 md:p-6 pb-3 md:pb-4 flex flex-col gap-3 md:gap-4 shrink-0 bg-surface/70">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
@@ -652,7 +652,7 @@ export default function InventoryPage() {
|
|||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted font-normal mt-1">Manage physical box labels & printing</p>
|
<p className="text-sm text-muted font-normal mt-1">Manage physical box labels & printing</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => { setShowBoxManager(false); setBoxSearchQuery(''); }} className="p-3 hover:bg-slate-800 rounded-full transition-colors text-secondary">
|
<button onClick={() => { setShowBoxManager(false); setBoxSearchQuery(''); }} className="p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8 hover:bg-slate-800 rounded-full transition-colors text-secondary">
|
||||||
<X size={24} />
|
<X size={24} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -663,7 +663,7 @@ export default function InventoryPage() {
|
|||||||
placeholder="Search boxes..."
|
placeholder="Search boxes..."
|
||||||
value={boxSearchQuery}
|
value={boxSearchQuery}
|
||||||
onChange={(e) => setBoxSearchQuery(e.target.value)}
|
onChange={(e) => setBoxSearchQuery(e.target.value)}
|
||||||
className="w-full bg-background border border-slate-800 rounded-2xl py-3.5 pl-11 pr-4 text-sm focus:border-primary outline-none transition-all placeholder:text-secondary"
|
className="w-full bg-background border border-slate-800 rounded-2xl py-3 lg:py-4 xl:py-5.5 pl-11 pr-4 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl focus:border-primary outline-none transition-all placeholder:text-secondary"
|
||||||
/>
|
/>
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-secondary" size={18} />
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-secondary" size={18} />
|
||||||
{boxSearchQuery && (
|
{boxSearchQuery && (
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export default function LoginPage() {
|
|||||||
key={user.id}
|
key={user.id}
|
||||||
data-testid="user-list-item"
|
data-testid="user-list-item"
|
||||||
onClick={() => handleSelectUser(user)}
|
onClick={() => handleSelectUser(user)}
|
||||||
className="bg-slate-800/50 hover:bg-slate-800 border border-slate-800 hover:border-primary/40 p-4 rounded-2xl text-left transition-all group flex items-center justify-between"
|
className="bg-slate-800/50 hover:bg-slate-800 border border-slate-800 hover:border-primary/40 p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 rounded-2xl text-left transition-all group flex items-center justify-between"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-full bg-surface border border-slate-700 flex items-center justify-center text-muted group-hover:text-primary transition-colors">
|
<div className="w-8 h-8 rounded-full bg-surface border border-slate-700 flex items-center justify-center text-muted group-hover:text-primary transition-colors">
|
||||||
@@ -196,7 +196,7 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4 animate-in slide-in-from-right-4 duration-300">
|
<div className="space-y-4 animate-in slide-in-from-right-4 duration-300">
|
||||||
<div className="bg-slate-800/30 p-4 rounded-2xl border border-slate-800 flex items-center gap-3">
|
<div className="bg-slate-800/30 p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 rounded-2xl border border-slate-800 flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedUserForLogin(null)}
|
onClick={() => setSelectedUserForLogin(null)}
|
||||||
className="p-1 hover:bg-slate-700 rounded-lg transition-colors"
|
className="p-1 hover:bg-slate-700 rounded-lg transition-colors"
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export default function LogsPage() {
|
|||||||
<main className="p-3 md:p-8 max-w-7xl mx-auto space-y-3 md:space-y-6 mb-20">
|
<main className="p-3 md:p-8 max-w-7xl mx-auto space-y-3 md:space-y-6 mb-20">
|
||||||
<header className="flex flex-col sm:flex-row sm:items-end justify-between gap-3 md:gap-4">
|
<header className="flex flex-col sm:flex-row sm:items-end justify-between gap-3 md:gap-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="p-3 md:p-4 bg-primary/10 rounded-2xl text-primary border border-primary/20 shadow-xl shadow-primary/5">
|
<div className="p-3 md:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-primary/10 rounded-2xl text-primary border border-primary/20 shadow-xl shadow-primary/5">
|
||||||
<History size={28} className="md:w-8 md:h-8" />
|
<History size={28} className="md:w-8 md:h-8" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -142,7 +142,7 @@ export default function LogsPage() {
|
|||||||
placeholder="Filter by user, action or asset..."
|
placeholder="Filter by user, action or asset..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="w-full bg-surface/50 border border-slate-800 rounded-2xl py-3.5 pr-4 pl-12 text-sm focus:border-primary outline-none transition-all placeholder:text-secondary shadow-2xl"
|
className="w-full bg-surface/50 border border-slate-800 rounded-2xl py-3 lg:py-4 xl:py-5.5 pr-4 pl-12 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl focus:border-primary outline-none transition-all placeholder:text-secondary shadow-2xl"
|
||||||
/>
|
/>
|
||||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-secondary transition-colors group-focus-within:text-primary">
|
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-secondary transition-colors group-focus-within:text-primary">
|
||||||
<Search size={18} />
|
<Search size={18} />
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ export default function Home() {
|
|||||||
onClick={handleSync}
|
onClick={handleSync}
|
||||||
disabled={syncing}
|
disabled={syncing}
|
||||||
data-testid="manual-sync-button"
|
data-testid="manual-sync-button"
|
||||||
className="p-2.5 bg-surface border border-slate-800 text-secondary rounded-xl hover:text-white transition-all active:scale-95 disabled:opacity-50"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10.5 bg-surface border border-slate-800 text-secondary rounded-xl hover:text-white transition-all active:scale-95 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<RefreshCw size={18} className={syncing ? "animate-spin text-primary" : ""} />
|
<RefreshCw size={18} className={syncing ? "animate-spin text-primary" : ""} />
|
||||||
</button>
|
</button>
|
||||||
@@ -507,7 +507,7 @@ export default function Home() {
|
|||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-secondary font-normal mt-1">Select the item you want to {mode.replace('_', ' ').toLowerCase()}</p>
|
<p className="text-xs text-secondary font-normal mt-1">Select the item you want to {mode.replace('_', ' ').toLowerCase()}</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setBoxMatches([])} className="p-2 hover:bg-slate-800 rounded-full">
|
<button onClick={() => setBoxMatches([])} className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 hover:bg-slate-800 rounded-full">
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -539,7 +539,7 @@ export default function Home() {
|
|||||||
<footer className="mt-12 md:mt-20 mb-4 md:mb-8 flex flex-col items-center gap-2 opacity-70">
|
<footer className="mt-12 md:mt-20 mb-4 md:mb-8 flex flex-col items-center gap-2 opacity-70">
|
||||||
<p className="text-sm font-normal text-secondary">Powered by TFM Group Software</p>
|
<p className="text-sm font-normal text-secondary">Powered by TFM Group Software</p>
|
||||||
<div className="h-px w-16 bg-slate-700/60" />
|
<div className="h-px w-16 bg-slate-700/60" />
|
||||||
<p className="text-xs font-mono text-secondary">v{versionData.version} • {versionData.last_build} • BUILD: dev-{(versionData as any).commit || 'N/A'}</p>
|
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl font-mono text-secondary">v{versionData.version} • {versionData.last_build} • BUILD: dev-{(versionData as any).commit || 'N/A'}</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|||||||
@@ -58,10 +58,10 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="ai-extraction-overlay" className="fixed inset-0 z-50 bg-background flex flex-col p-6 animate-in fade-in slide-in-from-bottom-5 duration-300">
|
<div data-testid="ai-extraction-overlay" className="fixed inset-0 z-50 bg-background flex flex-col p-6 lg:p-8 xl:p-10 animate-in fade-in slide-in-from-bottom-5 duration-300">
|
||||||
<div className="flex justify-between items-center mb-6 shrink-0">
|
<div className="flex justify-between items-center mb-6 shrink-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-primary/20 rounded-xl">
|
<div className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-primary/20 rounded-xl">
|
||||||
<Sparkles className="text-primary w-5 h-5" />
|
<Sparkles className="text-primary w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -72,7 +72,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
|||||||
<button
|
<button
|
||||||
onClick={() => { stopLiveCamera(); onCancel(); }}
|
onClick={() => { stopLiveCamera(); onCancel(); }}
|
||||||
aria-label="Close AI Discovery"
|
aria-label="Close AI Discovery"
|
||||||
className="p-2 hover:bg-surface rounded-full cursor-pointer transition-colors focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 hover:bg-surface rounded-full cursor-pointer transition-colors focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
>
|
>
|
||||||
<X size={24} />
|
<X size={24} />
|
||||||
</button>
|
</button>
|
||||||
@@ -87,7 +87,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
|||||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl font-normal cursor-pointer transition-all focus:ring-2 focus:ring-primary focus:outline-none ${mode === 'item' ? 'bg-primary text-white shadow-lg' : 'text-muted hover:text-secondary'}`}
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl font-normal cursor-pointer transition-all focus:ring-2 focus:ring-primary focus:outline-none ${mode === 'item' ? 'bg-primary text-white shadow-lg' : 'text-muted hover:text-secondary'}`}
|
||||||
>
|
>
|
||||||
<Package size={18} />
|
<Package size={18} />
|
||||||
<span className="text-xs">Discovery Mode</span>
|
<span className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl">Discovery Mode</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMode('box')}
|
onClick={() => setMode('box')}
|
||||||
@@ -95,11 +95,11 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
|||||||
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl font-normal cursor-pointer transition-all focus:ring-2 focus:ring-primary focus:outline-none ${mode === 'box' ? 'bg-primary text-white shadow-lg' : 'text-muted hover:text-secondary'}`}
|
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl font-normal cursor-pointer transition-all focus:ring-2 focus:ring-primary focus:outline-none ${mode === 'box' ? 'bg-primary text-white shadow-lg' : 'text-muted hover:text-secondary'}`}
|
||||||
>
|
>
|
||||||
<Layers size={18} />
|
<Layers size={18} />
|
||||||
<span className="text-xs">Box Lookup</span>
|
<span className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl">Box Lookup</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col items-center justify-center border-2 border-dashed border-slate-800 rounded-[2.5rem] bg-surface/30 overflow-hidden px-4">
|
<div className="flex-1 flex flex-col items-center justify-center border-2 border-dashed border-slate-800 rounded-[2.5rem] bg-surface/30 overflow-hidden px-4 lg:px-5 xl:px-6">
|
||||||
<div className="w-20 h-20 bg-surface rounded-3xl flex items-center justify-center mb-6 shadow-inner text-primary">
|
<div className="w-20 h-20 bg-surface rounded-3xl flex items-center justify-center mb-6 shadow-inner text-primary">
|
||||||
<Camera size={32} />
|
<Camera size={32} />
|
||||||
</div>
|
</div>
|
||||||
@@ -120,7 +120,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
|||||||
className="flex flex-col items-center justify-center gap-2 bg-primary text-white rounded-3xl font-normal shadow-2xl shadow-primary/20 cursor-pointer active:scale-95 transition-all focus:ring-2 focus:ring-blue-400 focus:outline-none"
|
className="flex flex-col items-center justify-center gap-2 bg-primary text-white rounded-3xl font-normal shadow-2xl shadow-primary/20 cursor-pointer active:scale-95 transition-all focus:ring-2 focus:ring-blue-400 focus:outline-none"
|
||||||
>
|
>
|
||||||
<Camera size={24} />
|
<Camera size={24} />
|
||||||
<span className="text-sm">Scan Camera</span>
|
<span className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl">Scan Camera</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
@@ -129,7 +129,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
|||||||
className="flex flex-col items-center justify-center gap-2 bg-surface text-secondary border border-slate-800 rounded-3xl font-normal cursor-pointer active:scale-95 transition-all focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
className="flex flex-col items-center justify-center gap-2 bg-surface text-secondary border border-slate-800 rounded-3xl font-normal cursor-pointer active:scale-95 transition-all focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||||
>
|
>
|
||||||
<ImageIcon size={24} />
|
<ImageIcon size={24} />
|
||||||
<span className="text-sm">Upload Photo</span>
|
<span className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl">Upload Photo</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -339,7 +339,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
|||||||
<textarea
|
<textarea
|
||||||
value={extractedItems[editingIndex].Description || extractedItems[editingIndex].description || ''}
|
value={extractedItems[editingIndex].Description || extractedItems[editingIndex].description || ''}
|
||||||
onChange={(e) => updateEditingItem({ Description: e.target.value })}
|
onChange={(e) => updateEditingItem({ Description: e.target.value })}
|
||||||
className="bg-transparent w-full text-sm leading-tight outline-none resize-none h-8 text-secondary py-0"
|
className="bg-transparent w-full text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl leading-tight outline-none resize-none h-8 text-secondary py-0"
|
||||||
placeholder="Product description..."
|
placeholder="Product description..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -422,7 +422,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
|||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
data-testid="extraction-item-row"
|
data-testid="extraction-item-row"
|
||||||
className="bg-surface/70 border border-slate-800 rounded-3xl p-5 flex items-center justify-between group hover:border-primary/40 transition-all active:scale-[0.98] focus-within:border-primary/60 focus-within:ring-2 focus-within:ring-primary/20"
|
className="bg-surface/70 border border-slate-800 rounded-3xl p-5 lg:p-6 xl:p-8 flex items-center justify-between group hover:border-primary/40 transition-all active:scale-[0.98] focus-within:border-primary/60 focus-within:ring-2 focus-within:ring-primary/20"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex-1 min-w-0 cursor-pointer"
|
className="flex-1 min-w-0 cursor-pointer"
|
||||||
@@ -461,14 +461,14 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
|
|||||||
setExtractedItems(newItems);
|
setExtractedItems(newItems);
|
||||||
}}
|
}}
|
||||||
aria-label={`Delete item: ${item.Item || item.name}`}
|
aria-label={`Delete item: ${item.Item || item.name}`}
|
||||||
className="p-3 text-secondary hover:text-red-500 cursor-pointer transition-colors focus:ring-2 focus:ring-red-500 focus:outline-none rounded-lg"
|
className="p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8 text-secondary hover:text-red-500 cursor-pointer transition-colors focus:ring-2 focus:ring-red-500 focus:outline-none rounded-lg"
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingIndex(idx)}
|
onClick={() => setEditingIndex(idx)}
|
||||||
aria-label={`Edit item: ${item.Item || item.name}`}
|
aria-label={`Edit item: ${item.Item || item.name}`}
|
||||||
className="p-3 bg-primary/10 text-primary rounded-2xl cursor-pointer hover:bg-primary/20 transition-all focus:ring-2 focus:ring-primary focus:outline-none"
|
className="p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8 bg-primary/10 text-primary rounded-2xl cursor-pointer hover:bg-primary/20 transition-all focus:ring-2 focus:ring-primary focus:outline-none"
|
||||||
>
|
>
|
||||||
<ChevronDown size={20} className="-rotate-90" />
|
<ChevronDown size={20} className="-rotate-90" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export default function AdminOverlay({
|
|||||||
<div className="absolute inset-y-0 right-0 w-full max-w-md sm:max-w-lg md:max-w-md bg-surface border-l border-slate-800 shadow-2xl flex flex-col animate-in slide-in-from-right duration-500">
|
<div className="absolute inset-y-0 right-0 w-full max-w-md sm:max-w-lg md:max-w-md bg-surface border-l border-slate-800 shadow-2xl flex flex-col animate-in slide-in-from-right duration-500">
|
||||||
<div className="p-3 md:p-6 border-b border-slate-800 flex items-center justify-between">
|
<div className="p-3 md:p-6 border-b border-slate-800 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-slate-800 rounded-xl text-primary">
|
<div className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-slate-800 rounded-xl text-primary">
|
||||||
<Shield size={20} />
|
<Shield size={20} />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-normal text-white">System Admin</h2>
|
<h2 className="text-xl font-normal text-white">System Admin</h2>
|
||||||
@@ -125,7 +125,7 @@ export default function AdminOverlay({
|
|||||||
<button
|
<button
|
||||||
data-testid="close-admin-overlay"
|
data-testid="close-admin-overlay"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 hover:bg-slate-800 rounded-full transition-colors text-muted focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 hover:bg-slate-800 rounded-full transition-colors text-muted focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
@@ -171,7 +171,7 @@ export default function AdminOverlay({
|
|||||||
loading: false,
|
loading: false,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="p-2 text-rose-500 hover:bg-rose-500/10 rounded-lg transition-all focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:outline-none"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 text-rose-500 hover:bg-rose-500/10 rounded-lg transition-all focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:outline-none"
|
||||||
aria-label={`Delete user ${u.username}`}
|
aria-label={`Delete user ${u.username}`}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
@@ -220,7 +220,7 @@ export default function AdminOverlay({
|
|||||||
loading: false,
|
loading: false,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="p-2 text-rose-500 hover:bg-rose-500/10 rounded-lg transition-all focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:outline-none"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 text-rose-500 hover:bg-rose-500/10 rounded-lg transition-all focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:outline-none"
|
||||||
aria-label={`Delete category ${cat.name}`}
|
aria-label={`Delete category ${cat.name}`}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
@@ -235,7 +235,7 @@ export default function AdminOverlay({
|
|||||||
<AlertTriangle size={16} />
|
<AlertTriangle size={16} />
|
||||||
<p className="text-sm font-normal">Sign Out</p>
|
<p className="text-sm font-normal">Sign Out</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted">End your session and return to the login screen.</p>
|
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-muted">End your session and return to the login screen.</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
import('@/lib/auth').then(m => m.clearAuth());
|
import('@/lib/auth').then(m => m.clearAuth());
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function BottomNav({
|
|||||||
const isAdmin = pathname === '/admin';
|
const isAdmin = pathname === '/admin';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="fixed bottom-0 left-0 right-0 py-3 px-2 pb-safe bg-background border-t border-slate-900 z-40 transition-all duration-300">
|
<footer className="fixed bottom-0 left-0 right-0 py-3 lg:py-4 xl:py-5 px-2 pb-safe bg-background border-t border-slate-900 z-40 transition-all duration-300">
|
||||||
<div className="max-w-xl mx-auto flex justify-around items-center text-secondary">
|
<div className="max-w-xl mx-auto flex justify-around items-center text-secondary">
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function CameraView({
|
|||||||
return (
|
return (
|
||||||
<div className="w-full max-w-md mx-auto flex flex-col gap-3 md:gap-4">
|
<div className="w-full max-w-md mx-auto flex flex-col gap-3 md:gap-4">
|
||||||
{/* Video Viewport Area */}
|
{/* Video Viewport Area */}
|
||||||
<div className="relative w-full aspect-square overflow-hidden rounded-[2.5rem] shadow-[0_20px_50px_rgba(0,0,0,0.5)] bg-background border-2 border-slate-800/50 p-4 sm:p-6">
|
<div className="relative w-full aspect-square overflow-hidden rounded-[2.5rem] shadow-[0_20px_50px_rgba(0,0,0,0.5)] bg-background border-2 border-slate-800/50 p-4 sm:p-6 lg:p-8 xl:p-10">
|
||||||
<div className="absolute inset-4 sm:inset-6 z-10 pointer-events-none flex items-center justify-center">
|
<div className="absolute inset-4 sm:inset-6 z-10 pointer-events-none flex items-center justify-center">
|
||||||
<div className="w-full h-full border border-primary/30 rounded-[2rem] relative">
|
<div className="w-full h-full border border-primary/30 rounded-[2rem] relative">
|
||||||
<div className="absolute top-0 left-0 w-10 h-10 border-t-4 border-l-4 border-primary rounded-tl-2xl shadow-[0_0_15px_rgba(59,130,246,0.5)]" />
|
<div className="absolute top-0 left-0 w-10 h-10 border-t-4 border-l-4 border-primary rounded-tl-2xl shadow-[0_0_15px_rgba(59,130,246,0.5)]" />
|
||||||
@@ -89,7 +89,7 @@ export default function CameraView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 bg-surface/90 border-t border-slate-800 flex flex-col gap-4">
|
<div className="p-6 lg:p-8 xl:p-10 bg-surface/90 border-t border-slate-800 flex flex-col gap-4">
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<p className="text-sm font-normal text-white italic text-center">Text Found</p>
|
<p className="text-sm font-normal text-white italic text-center">Text Found</p>
|
||||||
<p className="text-xs text-muted font-normal text-center">Tap any text to use it</p>
|
<p className="text-xs text-muted font-normal text-center">Tap any text to use it</p>
|
||||||
@@ -108,7 +108,7 @@ export default function CameraView({
|
|||||||
{!isStarted && !error && (
|
{!isStarted && !error && (
|
||||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-surface text-secondary gap-4">
|
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-surface text-secondary gap-4">
|
||||||
<RefreshCw className="w-8 h-8 animate-spin text-primary" />
|
<RefreshCw className="w-8 h-8 animate-spin text-primary" />
|
||||||
<p className="text-sm font-medium">Initializing camera...</p>
|
<p className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-medium">Initializing camera...</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ export default function CameraView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* External Controls Area */}
|
{/* External Controls Area */}
|
||||||
<div className="flex flex-col gap-4 bg-surface/50 p-5 rounded-[2.5rem] border border-slate-800/50 shadow-2xl">
|
<div className="flex flex-col gap-4 bg-surface/50 p-5 lg:p-6 xl:p-8 rounded-[2.5rem] border border-slate-800/50 shadow-2xl">
|
||||||
<div className="flex items-center gap-3 w-full">
|
<div className="flex items-center gap-3 w-full">
|
||||||
{hasZoom && (
|
{hasZoom && (
|
||||||
<button
|
<button
|
||||||
@@ -153,7 +153,7 @@ export default function CameraView({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 h-14 bg-background/40 border border-slate-800/50 rounded-2xl flex items-center justify-center gap-3 px-4 relative overflow-hidden group">
|
<div className="flex-1 h-14 bg-background/40 border border-slate-800/50 rounded-2xl flex items-center justify-center gap-3 px-4 lg:px-5 xl:px-6 relative overflow-hidden group">
|
||||||
{ocrProcessing ? (
|
{ocrProcessing ? (
|
||||||
<>
|
<>
|
||||||
<RefreshCw className="animate-spin text-primary" size={18} />
|
<RefreshCw className="animate-spin text-primary" size={18} />
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ export default function CategoryCreationModal({
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
<div className="bg-surface border border-slate-800 rounded-lg shadow-xl max-w-sm w-full mx-4">
|
<div className="bg-surface border border-slate-800 rounded-lg shadow-xl max-w-sm w-full mx-4">
|
||||||
<div className="flex items-center justify-between p-6 border-b border-slate-800">
|
<div className="flex items-center justify-between p-6 lg:p-8 xl:p-10 border-b border-slate-800">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-primary/10 rounded-lg text-primary">
|
<div className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-primary/10 rounded-lg text-primary">
|
||||||
<Tag size={20} />
|
<Tag size={20} />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-normal text-white">New Category</h2>
|
<h2 className="text-lg font-normal text-white">New Category</h2>
|
||||||
@@ -69,7 +69,7 @@ export default function CategoryCreationModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
<form onSubmit={handleSubmit} className="p-6 lg:p-8 xl:p-10 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-normal text-secondary">Name</label>
|
<label className="text-sm font-normal text-secondary">Name</label>
|
||||||
<input
|
<input
|
||||||
@@ -80,7 +80,7 @@ export default function CategoryCreationModal({
|
|||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
placeholder="e.g., Electronics"
|
placeholder="e.g., Electronics"
|
||||||
className="w-full mt-2 px-3 py-2.5 bg-slate-800 border border-slate-700 rounded text-white placeholder:text-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
className="w-full mt-2 px-3 lg:px-4 xl:px-5 py-2 lg:py-3 xl:py-4.5 bg-slate-800 border border-slate-700 rounded text-white placeholder:text-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
@@ -93,14 +93,14 @@ export default function CategoryCreationModal({
|
|||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder="Brief category description"
|
placeholder="Brief category description"
|
||||||
className="w-full mt-2 px-3 py-2.5 bg-slate-800 border border-slate-700 rounded text-white placeholder:text-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
className="w-full mt-2 px-3 lg:px-4 xl:px-5 py-2 lg:py-3 xl:py-4.5 bg-slate-800 border border-slate-700 rounded text-white placeholder:text-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-3 bg-rose-500/10 border border-rose-500/20 rounded">
|
<div className="p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8 bg-rose-500/10 border border-rose-500/20 rounded">
|
||||||
<p className="text-sm text-rose-400">{error}</p>
|
<p className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl text-rose-400">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ export default function ConfirmationModal({
|
|||||||
<div data-testid="confirmation-modal" className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
<div data-testid="confirmation-modal" className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
<div className="bg-surface border border-slate-800 rounded-lg shadow-xl max-w-sm w-full mx-4">
|
<div className="bg-surface border border-slate-800 rounded-lg shadow-xl max-w-sm w-full mx-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-slate-800">
|
<div className="flex items-center justify-between p-6 lg:p-8 xl:p-10 border-b border-slate-800">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-rose-500/10 rounded-lg text-rose-500">
|
<div className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-rose-500/10 rounded-lg text-rose-500">
|
||||||
<AlertTriangle size={20} />
|
<AlertTriangle size={20} />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-lg font-normal text-white">{title}</h2>
|
<h2 className="text-lg font-normal text-white">{title}</h2>
|
||||||
@@ -75,9 +75,9 @@ export default function ConfirmationModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 lg:p-8 xl:p-10 space-y-4">
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<p className="text-sm text-secondary">{description}</p>
|
<p className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl text-secondary">{description}</p>
|
||||||
|
|
||||||
{/* Item Name (if provided) */}
|
{/* Item Name (if provided) */}
|
||||||
{itemName && (
|
{itemName && (
|
||||||
@@ -89,15 +89,15 @@ export default function ConfirmationModal({
|
|||||||
|
|
||||||
{/* Consequence Warning */}
|
{/* Consequence Warning */}
|
||||||
{consequence && (
|
{consequence && (
|
||||||
<div className="flex gap-2 p-3 bg-rose-500/5 border border-rose-500/20 rounded">
|
<div className="flex gap-2 p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8 bg-rose-500/5 border border-rose-500/20 rounded">
|
||||||
<span className="text-rose-500 flex-shrink-0">⚠</span>
|
<span className="text-rose-500 flex-shrink-0">⚠</span>
|
||||||
<p className="text-sm text-rose-400">{consequence}</p>
|
<p className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl text-rose-400">{consequence}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Affected Items Count (for medium-risk+) */}
|
{/* Affected Items Count (for medium-risk+) */}
|
||||||
{affectedCount && affectedCount > 0 && (
|
{affectedCount && affectedCount > 0 && (
|
||||||
<div className="text-sm text-secondary">
|
<div className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl text-secondary">
|
||||||
{affectedCount === 1
|
{affectedCount === 1
|
||||||
? 'This will affect 1 item.'
|
? 'This will affect 1 item.'
|
||||||
: `This will affect ${affectedCount} items.`}
|
: `This will affect ${affectedCount} items.`}
|
||||||
@@ -118,7 +118,7 @@ export default function ConfirmationModal({
|
|||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
placeholder="Type DELETE"
|
placeholder="Type DELETE"
|
||||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-700 rounded text-white placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
|
className="w-full px-3 lg:px-4 xl:px-5 py-2 lg:py-3 xl:py-4.5 bg-slate-800 border border-slate-700 rounded text-white placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && canConfirm && !loading) {
|
if (e.key === 'Enter' && canConfirm && !loading) {
|
||||||
@@ -127,14 +127,14 @@ export default function ConfirmationModal({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-xs text-rose-500">{error}</p>
|
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-rose-500">{error}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-2 p-6 border-t border-slate-800">
|
<div className="flex gap-2 p-6 lg:p-8 xl:p-10 border-t border-slate-800">
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export default function CreateUserModal({ show, onClose, onUserCreated }: Create
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
<form onSubmit={handleSubmit} className="p-6 lg:p-8 xl:p-10 space-y-4">
|
||||||
{/* Username Field */}
|
{/* Username Field */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="username" className="block text-sm font-normal text-secondary mb-2">
|
<label htmlFor="username" className="block text-sm font-normal text-secondary mb-2">
|
||||||
@@ -101,13 +101,13 @@ export default function CreateUserModal({ show, onClose, onUserCreated }: Create
|
|||||||
if (errors.username) setErrors({ ...errors, username: undefined });
|
if (errors.username) setErrors({ ...errors, username: undefined });
|
||||||
}}
|
}}
|
||||||
placeholder="Enter username"
|
placeholder="Enter username"
|
||||||
className={`w-full px-3 py-2.5 bg-slate-800 border rounded text-white placeholder:text-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary ${
|
className={`w-full px-3 lg:px-4 xl:px-5 py-2 lg:py-3 xl:py-4.5 bg-slate-800 border rounded text-white placeholder:text-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary ${
|
||||||
errors.username ? 'border-red-500' : 'border-slate-700'
|
errors.username ? 'border-red-500' : 'border-slate-700'
|
||||||
}`}
|
}`}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
{errors.username && (
|
{errors.username && (
|
||||||
<p className="mt-1.5 text-sm text-red-500">{errors.username}</p>
|
<p className="mt-1.5 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl text-red-500">{errors.username}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -126,13 +126,13 @@ export default function CreateUserModal({ show, onClose, onUserCreated }: Create
|
|||||||
if (errors.password) setErrors({ ...errors, password: undefined });
|
if (errors.password) setErrors({ ...errors, password: undefined });
|
||||||
}}
|
}}
|
||||||
placeholder="Enter password"
|
placeholder="Enter password"
|
||||||
className={`w-full px-3 py-2.5 bg-slate-800 border rounded text-white placeholder:text-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary ${
|
className={`w-full px-3 lg:px-4 xl:px-5 py-2 lg:py-3 xl:py-4.5 bg-slate-800 border rounded text-white placeholder:text-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-primary ${
|
||||||
errors.password ? 'border-red-500' : 'border-slate-700'
|
errors.password ? 'border-red-500' : 'border-slate-700'
|
||||||
}`}
|
}`}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
{errors.password && (
|
{errors.password && (
|
||||||
<p className="mt-1.5 text-sm text-red-500">{errors.password}</p>
|
<p className="mt-1.5 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl text-red-500">{errors.password}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default function FilterBar({ searchQuery, onChange }: FilterBarProps) {
|
|||||||
placeholder="Search catalog..."
|
placeholder="Search catalog..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
className="w-full bg-surface border border-slate-800 rounded-2xl py-3.5 pr-4 pl-11 text-sm focus:border-primary outline-none transition-all placeholder:text-secondary"
|
className="w-full bg-surface border border-slate-800 rounded-2xl py-3 lg:py-4 xl:py-5.5 pr-4 pl-11 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl focus:border-primary outline-none transition-all placeholder:text-secondary"
|
||||||
/>
|
/>
|
||||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-secondary">
|
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-secondary">
|
||||||
<Search size={18} />
|
<Search size={18} />
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
|||||||
key={user.id}
|
key={user.id}
|
||||||
data-testid="user-list-item"
|
data-testid="user-list-item"
|
||||||
onClick={() => handleSelectUser(user)}
|
onClick={() => handleSelectUser(user)}
|
||||||
className="bg-slate-800/40 hover:bg-slate-800 border border-slate-800/50 hover:border-primary/40 p-5 rounded-[1.5rem] text-left transition-all group flex items-center justify-between shadow-sm active:scale-[0.98]"
|
className="bg-slate-800/40 hover:bg-slate-800 border border-slate-800/50 hover:border-primary/40 p-5 lg:p-6 xl:p-8 rounded-[1.5rem] text-left transition-all group flex items-center justify-between shadow-sm active:scale-[0.98]"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="w-10 h-10 rounded-2xl bg-surface border border-slate-800 flex items-center justify-center text-muted group-hover:text-primary transition-all group-hover:scale-110">
|
<div className="w-10 h-10 rounded-2xl bg-surface border border-slate-800 flex items-center justify-center text-muted group-hover:text-primary transition-all group-hover:scale-110">
|
||||||
@@ -126,7 +126,7 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
|||||||
data-testid="username-input"
|
data-testid="username-input"
|
||||||
type="text"
|
type="text"
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-full bg-background/50 border border-slate-800/80 focus:border-primary/50 focus:bg-background rounded-[1.25rem] py-4.5 pl-14 pr-5 text-sm text-secondary focus:outline-none transition-all placeholder:text-muted font-mono"
|
className="w-full bg-background/50 border border-slate-800/80 focus:border-primary/50 focus:bg-background rounded-[1.25rem] py-4.5 pl-14 pr-5 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl text-secondary focus:outline-none transition-all placeholder:text-muted font-mono"
|
||||||
placeholder="DIRECTORY ID"
|
placeholder="DIRECTORY ID"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,7 +140,7 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
|||||||
data-testid="password-input"
|
data-testid="password-input"
|
||||||
type="password"
|
type="password"
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
||||||
className="w-full bg-background/50 border border-slate-800/80 focus:border-primary/50 focus:bg-background rounded-[1.25rem] py-4.5 pl-14 pr-5 text-sm text-secondary focus:outline-none transition-all placeholder:text-muted font-mono"
|
className="w-full bg-background/50 border border-slate-800/80 focus:border-primary/50 focus:bg-background rounded-[1.25rem] py-4.5 pl-14 pr-5 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl text-secondary focus:outline-none transition-all placeholder:text-muted font-mono"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,7 +156,7 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-5 animate-in slide-in-from-bottom-5 duration-300">
|
<div className="space-y-5 animate-in slide-in-from-bottom-5 duration-300">
|
||||||
<div data-testid="user-display" className="bg-background/50 p-5 rounded-[1.5rem] border border-slate-800 flex items-center justify-between group">
|
<div data-testid="user-display" className="bg-background/50 p-5 lg:p-6 xl:p-8 rounded-[1.5rem] border border-slate-800 flex items-center justify-between group">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="w-10 h-10 rounded-2xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary shadow-lg">
|
<div className="w-10 h-10 rounded-2xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary shadow-lg">
|
||||||
<Shield size={20} />
|
<Shield size={20} />
|
||||||
@@ -168,7 +168,7 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedUserForLogin(null)}
|
onClick={() => setSelectedUserForLogin(null)}
|
||||||
className="p-2 hover:bg-slate-800 rounded-xl transition-all text-secondary hover:text-rose-500"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 hover:bg-slate-800 rounded-xl transition-all text-secondary hover:text-rose-500"
|
||||||
title="Change User"
|
title="Change User"
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
@@ -184,7 +184,7 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
|
|||||||
type="password"
|
type="password"
|
||||||
autoFocus
|
autoFocus
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
||||||
className="w-full bg-background/50 border border-slate-800/80 focus:border-primary/50 focus:bg-background rounded-[1.25rem] py-4.5 pl-14 pr-5 text-sm text-secondary focus:outline-none transition-all placeholder:text-muted font-mono"
|
className="w-full bg-background/50 border border-slate-800/80 focus:border-primary/50 focus:bg-background rounded-[1.25rem] py-4.5 pl-14 pr-5 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl text-secondary focus:outline-none transition-all placeholder:text-muted font-mono"
|
||||||
placeholder="KEY-PHRASE"
|
placeholder="KEY-PHRASE"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export default function InventoryTable({
|
|||||||
|
|
||||||
if (categories.length === 0) {
|
if (categories.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="py-20 text-center text-secondary">
|
<div className="py-2 lg:py-3 xl:py-40 text-center text-secondary">
|
||||||
<Package size={48} className="mx-auto mb-4 opacity-10" />
|
<Package size={48} className="mx-auto mb-4 opacity-10" />
|
||||||
<p className="font-medium">No results found</p>
|
<p className="font-medium">No results found</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,7 +67,7 @@ export default function InventoryTable({
|
|||||||
return (
|
return (
|
||||||
<div key={cat} className="bg-surface/50 border border-slate-800/50 rounded-3xl overflow-hidden transition-all duration-300">
|
<div key={cat} className="bg-surface/50 border border-slate-800/50 rounded-3xl overflow-hidden transition-all duration-300">
|
||||||
<div
|
<div
|
||||||
className="w-full p-4 md:p-5 flex items-center justify-between hover:bg-surface/60 transition-colors cursor-pointer"
|
className="w-full p-4 md:p-5 lg:p-6 xl:p-8 flex items-center justify-between hover:bg-surface/60 transition-colors cursor-pointer"
|
||||||
onClick={() => onExpandCategory(expandedCategory === cat ? null : cat)}
|
onClick={() => onExpandCategory(expandedCategory === cat ? null : cat)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -75,7 +75,7 @@ export default function InventoryTable({
|
|||||||
<Layers size={20} />
|
<Layers size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<h3 className="card-title text-base sm:text-lg">{cat}</h3>
|
<h3 className="card-title text-base sm:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl">{cat}</h3>
|
||||||
<p className="card-subtitle tracking-tight">
|
<p className="card-subtitle tracking-tight">
|
||||||
{categoryItems.length} Item types in stock
|
{categoryItems.length} Item types in stock
|
||||||
</p>
|
</p>
|
||||||
@@ -89,7 +89,7 @@ export default function InventoryTable({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onEditCategory(cat);
|
onEditCategory(cat);
|
||||||
}}
|
}}
|
||||||
className="p-2 hover:bg-slate-800 rounded-full text-muted hover:text-primary transition-colors relative z-10"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 hover:bg-slate-800 rounded-full text-muted hover:text-primary transition-colors relative z-10"
|
||||||
>
|
>
|
||||||
<EditIcon size={16} />
|
<EditIcon size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -98,7 +98,7 @@ export default function InventoryTable({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expandedCategory === cat && (
|
{expandedCategory === cat && (
|
||||||
<div className="p-4 pt-0 space-y-2 animate-in slide-in-from-top-4 duration-300">
|
<div className="p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 pt-0 space-y-2 animate-in slide-in-from-top-4 duration-300">
|
||||||
<div className="h-px bg-slate-800/50 mb-4 mx-2" />
|
<div className="h-px bg-slate-800/50 mb-4 mx-2" />
|
||||||
{categoryItems.map(item => (
|
{categoryItems.map(item => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function ItemComparisonModal({
|
|||||||
const hasChanges = fields.some(f => isDifferent(f.key));
|
const hasChanges = fields.some(f => isDifferent(f.key));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-background/80">
|
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-background/80">
|
||||||
<div className="bg-surface border border-slate-800 rounded-[2.5rem] p-8 w-full max-w-2xl shadow-2xl max-h-[90vh] overflow-y-auto">
|
<div className="bg-surface border border-slate-800 rounded-[2.5rem] p-8 w-full max-w-2xl shadow-2xl max-h-[90vh] overflow-y-auto">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<AlertTriangle size={24} className="text-amber-500" />
|
<AlertTriangle size={24} className="text-amber-500" />
|
||||||
@@ -68,7 +68,7 @@ export default function ItemComparisonModal({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={field.key}
|
key={field.key}
|
||||||
className={`grid grid-cols-3 gap-4 p-3 rounded-lg ${
|
className={`grid grid-cols-3 gap-4 p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8 rounded-lg ${
|
||||||
different ? 'bg-amber-500/10 border border-amber-500/20' : 'bg-slate-800/30'
|
different ? 'bg-amber-500/10 border border-amber-500/20' : 'bg-slate-800/30'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -85,8 +85,8 @@ export default function ItemComparisonModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!hasChanges && (
|
{!hasChanges && (
|
||||||
<div className="p-4 bg-slate-800/50 rounded-xl mb-6 border border-slate-700">
|
<div className="p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-slate-800/50 rounded-xl mb-6 border border-slate-700">
|
||||||
<p className="text-sm text-secondary">✓ Items are identical. No update needed.</p>
|
<p className="text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl text-secondary">✓ Items are identical. No update needed.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function LogsOverlay({ show, onClose, logs, inventory }: LogsOver
|
|||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex flex-col bg-background p-6 animate-in slide-in-from-bottom-20 duration-500">
|
<div className="fixed inset-0 z-50 flex flex-col bg-background p-6 lg:p-8 xl:p-10 animate-in slide-in-from-bottom-20 duration-500">
|
||||||
<div className="flex justify-between items-center mb-8">
|
<div className="flex justify-between items-center mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-normal tracking-tight">Audit History</h2>
|
<h2 className="text-2xl font-normal tracking-tight">Audit History</h2>
|
||||||
@@ -22,7 +22,7 @@ export default function LogsOverlay({ show, onClose, logs, inventory }: LogsOver
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-3 bg-surface rounded-full text-secondary"
|
className="p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8 bg-surface rounded-full text-secondary"
|
||||||
>
|
>
|
||||||
<X size={24} />
|
<X size={24} />
|
||||||
</button>
|
</button>
|
||||||
@@ -36,7 +36,7 @@ export default function LogsOverlay({ show, onClose, logs, inventory }: LogsOver
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
logs.map((log) => (
|
logs.map((log) => (
|
||||||
<div key={log.id} className="bg-surface/70 border border-slate-800 p-4 rounded-2xl flex items-center justify-between gap-4">
|
<div key={log.id} className="bg-surface/70 border border-slate-800 p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 rounded-2xl flex items-center justify-between gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<p className={`text-xs font-normal ${
|
<p className={`text-xs font-normal ${
|
||||||
@@ -54,11 +54,11 @@ export default function LogsOverlay({ show, onClose, logs, inventory }: LogsOver
|
|||||||
</div>
|
</div>
|
||||||
{(log.details || log.timestamp) && (
|
{(log.details || log.timestamp) && (
|
||||||
<div className="flex items-center gap-3 mt-2">
|
<div className="flex items-center gap-3 mt-2">
|
||||||
<p className="text-xs text-secondary font-mono">
|
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl text-secondary font-mono">
|
||||||
{new Date(log.timestamp).toLocaleString()}
|
{new Date(log.timestamp).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
{log.details && (
|
{log.details && (
|
||||||
<span className="text-xs bg-slate-800 text-muted px-2 py-0.5 rounded border border-slate-700 font-mono">
|
<span className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl bg-slate-800 text-muted px-2 py-0.5 rounded border border-slate-700 font-mono">
|
||||||
{log.details}
|
{log.details}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function LogsTable({ logs, loading }: LogsTableProps) {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-32 text-secondary gap-4 animate-pulse">
|
<div className="flex flex-col items-center justify-center py-3 lg:py-4 xl:py-52 text-secondary gap-4 animate-pulse">
|
||||||
<div className="w-10 h-10 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
<div className="w-10 h-10 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||||
<p className="text-xs font-normal tracking-widest italic">Securing Audit Stream...</p>
|
<p className="text-xs font-normal tracking-widest italic">Securing Audit Stream...</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,7 +53,7 @@ export default function LogsTable({ logs, loading }: LogsTableProps) {
|
|||||||
<button
|
<button
|
||||||
key={log.id}
|
key={log.id}
|
||||||
onClick={() => setSelectedLog(log)}
|
onClick={() => setSelectedLog(log)}
|
||||||
className="w-full text-left bg-surface/50 border border-slate-800/30 p-3 px-4 rounded-2xl flex items-center justify-between gap-4 hover:bg-slate-800/40 hover:border-primary/30 transition-all group active:scale-[0.99] relative overflow-hidden shadow-sm"
|
className="w-full text-left bg-surface/50 border border-slate-800/30 p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8 px-4 lg:px-5 xl:px-6 rounded-2xl flex items-center justify-between gap-4 hover:bg-slate-800/40 hover:border-primary/30 transition-all group active:scale-[0.99] relative overflow-hidden shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0 z-10 flex items-center gap-4">
|
<div className="flex-1 min-w-0 z-10 flex items-center gap-4">
|
||||||
{/* Compact Action Badge */}
|
{/* Compact Action Badge */}
|
||||||
@@ -111,7 +111,7 @@ export default function LogsTable({ logs, loading }: LogsTableProps) {
|
|||||||
{selectedLog.resolved_name}
|
{selectedLog.resolved_name}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setSelectedLog(null)} className="p-3 bg-slate-800/50 hover:bg-slate-800 rounded-2xl text-muted transition-colors border border-slate-800 shrink-0 shadow-lg">
|
<button onClick={() => setSelectedLog(null)} className="p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8 bg-slate-800/50 hover:bg-slate-800 rounded-2xl text-muted transition-colors border border-slate-800 shrink-0 shadow-lg">
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,12 +28,12 @@ export default function NewItemDialog({ onScannerClick, onAddItemClick }: NewIte
|
|||||||
onClick={onAddItemClick}
|
onClick={onAddItemClick}
|
||||||
className="w-full flex flex-col items-center justify-center p-4 md:p-6 rounded-[2rem] bg-indigo-500/5 border border-indigo-500/20 group hover:border-indigo-500/50 transition-all font-normal text-indigo-400 gap-2 md:gap-3"
|
className="w-full flex flex-col items-center justify-center p-4 md:p-6 rounded-[2rem] bg-indigo-500/5 border border-indigo-500/20 group hover:border-indigo-500/50 transition-all font-normal text-indigo-400 gap-2 md:gap-3"
|
||||||
>
|
>
|
||||||
<div className="p-4 bg-indigo-500/10 rounded-2xl group-hover:scale-110 transition-transform shadow-lg shadow-indigo-500/10">
|
<div className="p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-indigo-500/10 rounded-2xl group-hover:scale-110 transition-transform shadow-lg shadow-indigo-500/10">
|
||||||
<Sparkles size={32} />
|
<Sparkles size={32} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-lg leading-tight">Add New Item</p>
|
<p className="text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl leading-tight">Add New Item</p>
|
||||||
<p className="text-xs opacity-60 font-mono mt-1">AI Smart Discovery</p>
|
<p className="text-xs lg:text-sm xl:text-base lg:text-lg xl:text-xl lg:text-2xl xl:text-3xl lg:text-4xl xl:text-5xl opacity-60 font-mono mt-1">AI Smart Discovery</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,14 +54,14 @@ export default function ScannerSection({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scanner Section */}
|
{/* Scanner Section */}
|
||||||
<section className="glass-card rounded-3xl p-6">
|
<section className="glass-card rounded-3xl p-6 lg:p-8 xl:p-10">
|
||||||
{showScanner ? (
|
{showScanner ? (
|
||||||
<div className="space-y-2 md:space-y-3">
|
<div className="space-y-2 md:space-y-3">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h2 className="text-lg font-normal">scanning...</h2>
|
<h2 className="text-lg font-normal">scanning...</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => onShowScanner(false)}
|
onClick={() => onShowScanner(false)}
|
||||||
className="p-2.5 bg-surface border border-slate-800 text-secondary rounded-xl hover:text-rose-500 transition-all active:scale-95"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10.5 bg-surface border border-slate-800 text-secondary rounded-xl hover:text-rose-500 transition-all active:scale-95"
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface StatCardProps {
|
|||||||
|
|
||||||
export default function StatCard({ label, value, icon: Icon }: StatCardProps) {
|
export default function StatCard({ label, value, icon: Icon }: StatCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between items-center gap-2 p-3.5 bg-surface/70 border border-slate-800/50 rounded-2xl shadow-sm transition-all hover:bg-surface" role="status">
|
<div className="flex justify-between items-center gap-2 p-3 lg:p-4 xl:p-5 lg:p-6 xl:p-8.5 bg-surface/70 border border-slate-800/50 rounded-2xl shadow-sm transition-all hover:bg-surface" role="status">
|
||||||
<div className="flex items-center gap-2.5 min-w-0">
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
{Icon && <Icon className="w-5 h-5 text-primary flex-shrink-0" aria-hidden="true" />}
|
{Icon && <Icon className="w-5 h-5 text-primary flex-shrink-0" aria-hidden="true" />}
|
||||||
<span className="text-base md:text-lg text-secondary font-normal truncate">
|
<span className="text-base md:text-lg text-secondary font-normal truncate">
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export default function StockAdjustmentPanel({
|
|||||||
{!isEditing && (
|
{!isEditing && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onEdit(selectedItem)}
|
onClick={() => onEdit(selectedItem)}
|
||||||
className="p-2 hover:bg-slate-800 rounded-full text-secondary"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 hover:bg-slate-800 rounded-full text-secondary"
|
||||||
>
|
>
|
||||||
<Edit2 size={20} />
|
<Edit2 size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -85,7 +85,7 @@ export default function StockAdjustmentPanel({
|
|||||||
{isEditing && (
|
{isEditing && (
|
||||||
<button
|
<button
|
||||||
onClick={onDeleteItem}
|
onClick={onDeleteItem}
|
||||||
className="p-2 hover:bg-red-500/20 rounded-full text-red-500"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 hover:bg-red-500/20 rounded-full text-red-500"
|
||||||
>
|
>
|
||||||
<Trash2 size={20} />
|
<Trash2 size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -93,7 +93,7 @@ export default function StockAdjustmentPanel({
|
|||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
data-testid="adjustment-cancel"
|
data-testid="adjustment-cancel"
|
||||||
className="p-2 hover:bg-slate-800 rounded-full"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 hover:bg-slate-800 rounded-full"
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -119,7 +119,7 @@ export default function StockAdjustmentPanel({
|
|||||||
type="text"
|
type="text"
|
||||||
value={editedItem.part_number || ''}
|
value={editedItem.part_number || ''}
|
||||||
onChange={e => onEditChange({ ...editedItem, part_number: e.target.value })}
|
onChange={e => onEditChange({ ...editedItem, part_number: e.target.value })}
|
||||||
className="w-full bg-background border border-slate-800 rounded-xl py-3 px-4 text-sm font-mono outline-none text-secondary"
|
className="w-full bg-background border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl font-mono outline-none text-secondary"
|
||||||
placeholder="e.g. PN-12345"
|
placeholder="e.g. PN-12345"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,7 +131,7 @@ export default function StockAdjustmentPanel({
|
|||||||
list="existing-categories"
|
list="existing-categories"
|
||||||
value={editedItem.category || ''}
|
value={editedItem.category || ''}
|
||||||
onChange={e => onEditChange({ ...editedItem, category: e.target.value })}
|
onChange={e => onEditChange({ ...editedItem, category: e.target.value })}
|
||||||
className="w-full bg-background border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-secondary placeholder:text-muted"
|
className="w-full bg-background border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl outline-none text-secondary placeholder:text-muted"
|
||||||
placeholder="e.g. storage"
|
placeholder="e.g. storage"
|
||||||
/>
|
/>
|
||||||
<datalist id="existing-categories">
|
<datalist id="existing-categories">
|
||||||
@@ -147,7 +147,7 @@ export default function StockAdjustmentPanel({
|
|||||||
list="existing-types"
|
list="existing-types"
|
||||||
value={editedItem.type || ''}
|
value={editedItem.type || ''}
|
||||||
onChange={e => onEditChange({...editedItem, type: e.target.value})}
|
onChange={e => onEditChange({...editedItem, type: e.target.value})}
|
||||||
className="w-full bg-background border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-secondary"
|
className="w-full bg-background border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl outline-none text-secondary"
|
||||||
placeholder="e.g. spare parts"
|
placeholder="e.g. spare parts"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,7 +159,7 @@ export default function StockAdjustmentPanel({
|
|||||||
list="existing-boxes"
|
list="existing-boxes"
|
||||||
value={editedItem.box_label || ''}
|
value={editedItem.box_label || ''}
|
||||||
onChange={e => onEditChange({...editedItem, box_label: e.target.value})}
|
onChange={e => onEditChange({...editedItem, box_label: e.target.value})}
|
||||||
className="w-full bg-background border border-slate-800 rounded-xl py-3 pl-4 pr-12 text-sm outline-none text-secondary placeholder:text-muted focus:border-primary transition-colors"
|
className="w-full bg-background border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 pl-4 pr-12 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl outline-none text-secondary placeholder:text-muted focus:border-primary transition-colors"
|
||||||
placeholder="e.g. SFPs 40G Cisco"
|
placeholder="e.g. SFPs 40G Cisco"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -169,7 +169,7 @@ export default function StockAdjustmentPanel({
|
|||||||
toast.success("Scanning for BOX label...");
|
toast.success("Scanning for BOX label...");
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute right-2 p-2 rounded-lg transition-all",
|
"absolute right-2 p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 rounded-lg transition-all",
|
||||||
fieldScanning?.active ? "bg-primary text-white animate-pulse" : "text-muted hover:bg-slate-800"
|
fieldScanning?.active ? "bg-primary text-white animate-pulse" : "text-muted hover:bg-slate-800"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -183,7 +183,7 @@ export default function StockAdjustmentPanel({
|
|||||||
type="text"
|
type="text"
|
||||||
value={editedItem.connector || ''}
|
value={editedItem.connector || ''}
|
||||||
onChange={e => onEditChange({...editedItem, connector: e.target.value})}
|
onChange={e => onEditChange({...editedItem, connector: e.target.value})}
|
||||||
className="w-full bg-background border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-secondary"
|
className="w-full bg-background border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl outline-none text-secondary"
|
||||||
placeholder="e.g. LC/UPC"
|
placeholder="e.g. LC/UPC"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,7 +193,7 @@ export default function StockAdjustmentPanel({
|
|||||||
type="text"
|
type="text"
|
||||||
value={editedItem.size || ''}
|
value={editedItem.size || ''}
|
||||||
onChange={e => onEditChange({...editedItem, size: e.target.value})}
|
onChange={e => onEditChange({...editedItem, size: e.target.value})}
|
||||||
className="w-full bg-background border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-secondary"
|
className="w-full bg-background border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl outline-none text-secondary"
|
||||||
placeholder="e.g. 5m / 1600GB"
|
placeholder="e.g. 5m / 1600GB"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -203,7 +203,7 @@ export default function StockAdjustmentPanel({
|
|||||||
type="text"
|
type="text"
|
||||||
value={editedItem.color || ''}
|
value={editedItem.color || ''}
|
||||||
onChange={e => onEditChange({...editedItem, color: e.target.value})}
|
onChange={e => onEditChange({...editedItem, color: e.target.value})}
|
||||||
className="w-full bg-background border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-secondary"
|
className="w-full bg-background border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl outline-none text-secondary"
|
||||||
placeholder="e.g. Black"
|
placeholder="e.g. Black"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -212,7 +212,7 @@ export default function StockAdjustmentPanel({
|
|||||||
<textarea
|
<textarea
|
||||||
value={editedItem.description || ''}
|
value={editedItem.description || ''}
|
||||||
onChange={e => onEditChange({ ...editedItem, description: e.target.value })}
|
onChange={e => onEditChange({ ...editedItem, description: e.target.value })}
|
||||||
className="w-full bg-background border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-secondary resize-none h-20"
|
className="w-full bg-background border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl outline-none text-secondary resize-none h-20"
|
||||||
placeholder="Item description..."
|
placeholder="Item description..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -239,7 +239,7 @@ export default function StockAdjustmentPanel({
|
|||||||
key={t.id}
|
key={t.id}
|
||||||
onClick={() => onTypeChange(t.id as 'ADD' | 'REMOVE' | 'TRASH')}
|
onClick={() => onTypeChange(t.id as 'ADD' | 'REMOVE' | 'TRASH')}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 flex flex-col items-center py-3 rounded-xl transition-all",
|
"flex-1 flex flex-col items-center py-3 lg:py-4 xl:py-5 rounded-xl transition-all",
|
||||||
adjustType === t.id ? "bg-slate-800 shadow-lg" : "text-muted"
|
adjustType === t.id ? "bg-slate-800 shadow-lg" : "text-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -270,7 +270,7 @@ export default function StockAdjustmentPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{adjustType === 'TRASH' && (
|
{adjustType === 'TRASH' && (
|
||||||
<div className="w-full bg-red-500/5 border border-red-500/20 p-4 rounded-2xl animate-in shake duration-500">
|
<div className="w-full bg-red-500/5 border border-red-500/20 p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 rounded-2xl animate-in shake duration-500">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<AlertTriangle size={16} className="text-red-500" />
|
<AlertTriangle size={16} className="text-red-500" />
|
||||||
<span className="text-sm font-normal text-red-400">Waste Declaration</span>
|
<span className="text-sm font-normal text-red-400">Waste Declaration</span>
|
||||||
@@ -278,7 +278,7 @@ export default function StockAdjustmentPanel({
|
|||||||
<select
|
<select
|
||||||
value={trashReason}
|
value={trashReason}
|
||||||
onChange={(e) => onReasonChange(e.target.value)}
|
onChange={(e) => onReasonChange(e.target.value)}
|
||||||
className="w-full bg-background border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-secondary"
|
className="w-full bg-background border border-slate-800 rounded-xl py-3 lg:py-4 xl:py-5 px-4 lg:px-5 xl:px-6 text-sm lg:text-base xl:text-lg lg:text-xl xl:text-2xl lg:text-3xl xl:text-4xl outline-none text-secondary"
|
||||||
>
|
>
|
||||||
<option>Damaged</option>
|
<option>Damaged</option>
|
||||||
<option>Expired</option>
|
<option>Expired</option>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default function AiManager({
|
|||||||
data-testid="provider-option"
|
data-testid="provider-option"
|
||||||
onClick={() => handleUpdateAiProvider(p.id)}
|
onClick={() => handleUpdateAiProvider(p.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-4 rounded-2xl border transition-all text-left flex items-center justify-between group",
|
"p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 rounded-2xl border transition-all text-left flex items-center justify-between group",
|
||||||
p.active
|
p.active
|
||||||
? "bg-primary border-primary shadow-xl shadow-primary/10"
|
? "bg-primary border-primary shadow-xl shadow-primary/10"
|
||||||
: "bg-background/60 border-slate-800 hover:border-primary/40"
|
: "bg-background/60 border-slate-800 hover:border-primary/40"
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function CategoryManager({
|
|||||||
|
|
||||||
<div data-testid="category-list" className="grid sm:grid-cols-2 lg:grid-cols-4 gap-2.5">
|
<div data-testid="category-list" className="grid sm:grid-cols-2 lg:grid-cols-4 gap-2.5">
|
||||||
{categories.map(cat => (
|
{categories.map(cat => (
|
||||||
<div key={cat.id} data-testid="category-item" className="p-4 bg-background/40 border border-slate-800/50 rounded-2xl flex items-center justify-between group hover:border-primary/40 transition-all">
|
<div key={cat.id} data-testid="category-item" className="p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-background/40 border border-slate-800/50 rounded-2xl flex items-center justify-between group hover:border-primary/40 transition-all">
|
||||||
<div className="min-w-0 pr-4">
|
<div className="min-w-0 pr-4">
|
||||||
<p className="card-title group-hover:text-primary transition-colors">{cat.name}</p>
|
<p className="card-title group-hover:text-primary transition-colors">{cat.name}</p>
|
||||||
<p className="card-subtitle">{cat.description || 'General storage'}</p>
|
<p className="card-subtitle">{cat.description || 'General storage'}</p>
|
||||||
@@ -53,14 +53,14 @@ export default function CategoryManager({
|
|||||||
setEditingCategory(cat);
|
setEditingCategory(cat);
|
||||||
setEditCatForm({ name: cat.name, description: cat.description || '' });
|
setEditCatForm({ name: cat.name, description: cat.description || '' });
|
||||||
}}
|
}}
|
||||||
className="p-2 text-secondary hover:text-white hover:bg-slate-800 rounded-lg transition-all"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 text-secondary hover:text-white hover:bg-slate-800 rounded-lg transition-all"
|
||||||
>
|
>
|
||||||
<Edit2 size={14} />
|
<Edit2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onDeleteCategory(cat.id, cat.name)}
|
onClick={() => onDeleteCategory(cat.id, cat.name)}
|
||||||
data-testid="delete-category-button"
|
data-testid="delete-category-button"
|
||||||
className="p-2 text-secondary hover:text-rose-500 hover:bg-rose-500/5 rounded-lg transition-all"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 text-secondary hover:text-rose-500 hover:bg-rose-500/5 rounded-lg transition-all"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export default function DatabaseManager({
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => onRestore(bak.filename)}
|
onClick={() => onRestore(bak.filename)}
|
||||||
className="p-2 text-secondary hover:text-primary hover:bg-primary/5 rounded-xl transition-all opacity-0 group-hover/item:opacity-100 focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 text-secondary hover:text-primary hover:bg-primary/5 rounded-xl transition-all opacity-0 group-hover/item:opacity-100 focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none"
|
||||||
aria-label={`Restore backup ${bak.filename}`}
|
aria-label={`Restore backup ${bak.filename}`}
|
||||||
>
|
>
|
||||||
<RotateCcw size={16} />
|
<RotateCcw size={16} />
|
||||||
|
|||||||
@@ -1,136 +1,119 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FileDown, Loader2 } from "lucide-react";
|
import { FileDown, Loader2, FileSpreadsheet, FileText } from "lucide-react";
|
||||||
import { useExport } from "@/hooks/useExport";
|
import { useExport } from "@/hooks/useExport";
|
||||||
import { Toast } from "@/components/Toast";
|
import { toast } from "react-hot-toast";
|
||||||
|
|
||||||
export function ExportPanel() {
|
export function ExportPanel() {
|
||||||
const { exportSnapshot, exportAuditTrail, isLoading, error } = useExport();
|
const { exportSnapshot, exportAuditTrail, isLoading, error } = useExport();
|
||||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleExportSnapshot = async (format: "csv" | "xlsx") => {
|
const handleExportSnapshot = async (format: "csv" | "xlsx") => {
|
||||||
try {
|
try {
|
||||||
setSuccessMessage(null);
|
|
||||||
await exportSnapshot(format);
|
await exportSnapshot(format);
|
||||||
const formatName = format === "csv" ? "CSV" : "Excel";
|
const formatName = format === "csv" ? "CSV" : "Excel";
|
||||||
setSuccessMessage(`Inventory snapshot exported as ${formatName}`);
|
toast.success(`Inventory snapshot exported as ${formatName}`);
|
||||||
setTimeout(() => setSuccessMessage(null), 4000);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Error handled by useExport hook
|
// Error handled by hook
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExportAuditTrail = async (format: "csv" | "xlsx") => {
|
const handleExportAuditTrail = async (format: "csv" | "xlsx") => {
|
||||||
try {
|
try {
|
||||||
setSuccessMessage(null);
|
|
||||||
await exportAuditTrail(format);
|
await exportAuditTrail(format);
|
||||||
const formatName = format === "csv" ? "CSV" : "Excel";
|
const formatName = format === "csv" ? "CSV" : "Excel";
|
||||||
setSuccessMessage(`Audit trail exported as ${formatName}`);
|
toast.success(`Audit trail exported as ${formatName}`);
|
||||||
setTimeout(() => setSuccessMessage(null), 4000);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Error handled by useExport hook
|
// Error handled by hook
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div data-testid="admin-tab-exports" className="bg-surface/50 border border-slate-800/50 rounded-[2.5rem] p-4 md:p-6 flex flex-col shadow-2xl transition-all h-full">
|
||||||
<div className="rounded-lg border border-slate-200 bg-white p-6">
|
<div className="flex items-center gap-3 mb-4 md:mb-6">
|
||||||
<div className="mb-6 flex items-center gap-3">
|
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-400 border border-indigo-500/20">
|
||||||
<div className="rounded-lg bg-primary/10 p-4 border border-primary/20">
|
<FileDown size={20} />
|
||||||
<FileDown className="text-primary" size={24} />
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<h2 className="text-xl font-normal text-white tracking-tight">Export & Reports</h2>
|
||||||
<h2 className="text-2xl font-normal">Export & Reports</h2>
|
<p className="text-[10px] md:text-xs text-muted font-normal tracking-tight">Download system snapshots and action logs</p>
|
||||||
<p className="text-xs text-slate-500">
|
</div>
|
||||||
Download inventory snapshots and audit trails in CSV or Excel formats
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
{/* Inventory Snapshot Section */}
|
||||||
|
<div className="p-4 bg-background/40 border border-slate-800/40 rounded-2xl hover:border-primary/20 transition-all group flex flex-col justify-between">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-sm font-normal text-secondary flex items-center gap-2">
|
||||||
|
<FileSpreadsheet size={14} className="text-primary" />
|
||||||
|
Inventory Snapshot
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted mt-1 leading-relaxed">
|
||||||
|
Export current inventory state with all item details and locations.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{/* 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">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleExportSnapshot("csv")}
|
onClick={() => handleExportSnapshot("csv")}
|
||||||
disabled={isLoading}
|
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="px-5 py-1.5 bg-primary hover:bg-primary/90 text-white rounded-xl text-sm font-normal transition-all active:scale-95 shadow-xl shadow-primary/10 tracking-tight border border-primary/30 flex items-center justify-center gap-2 disabled:opacity-50"
|
||||||
aria-label="Export inventory snapshot as CSV"
|
aria-label="Export inventory snapshot as CSV"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? <Loader2 size={14} className="animate-spin" /> : <FileText size={14} />}
|
||||||
<Loader2 size={16} className="animate-spin" />
|
CSV
|
||||||
) : (
|
|
||||||
<FileDown size={16} />
|
|
||||||
)}
|
|
||||||
{isLoading ? "Exporting..." : "Export as CSV"}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleExportSnapshot("xlsx")}
|
onClick={() => handleExportSnapshot("xlsx")}
|
||||||
disabled={isLoading}
|
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="px-5 py-1.5 bg-primary hover:bg-primary/90 text-white rounded-xl text-sm font-normal transition-all active:scale-95 shadow-xl shadow-primary/10 tracking-tight border border-primary/30 flex items-center justify-center gap-2 disabled:opacity-50"
|
||||||
aria-label="Export inventory snapshot as Excel"
|
aria-label="Export inventory snapshot as Excel"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? <Loader2 size={14} className="animate-spin" /> : <FileSpreadsheet size={14} />}
|
||||||
<Loader2 size={16} className="animate-spin" />
|
Excel
|
||||||
) : (
|
|
||||||
<FileDown size={16} />
|
|
||||||
)}
|
|
||||||
{isLoading ? "Exporting..." : "Export as Excel"}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Audit Trail Section */}
|
{/* Audit Trail Section */}
|
||||||
<div className="space-y-3">
|
<div className="p-4 bg-background/40 border border-slate-800/40 rounded-2xl hover:border-primary/20 transition-all group flex flex-col justify-between">
|
||||||
<h3 className="font-normal text-slate-700">Audit Trail</h3>
|
<div className="mb-4">
|
||||||
<p className="text-sm text-slate-500">
|
<h3 className="text-sm font-normal text-secondary flex items-center gap-2">
|
||||||
Export complete audit log of all system actions and changes
|
<FileText size={14} className="text-indigo-400" />
|
||||||
</p>
|
Audit Trail
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
</h3>
|
||||||
|
<p className="text-xs text-muted mt-1 leading-relaxed">
|
||||||
|
Complete history of system actions, changes, and user operations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleExportAuditTrail("csv")}
|
onClick={() => handleExportAuditTrail("csv")}
|
||||||
disabled={isLoading}
|
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="px-5 py-1.5 bg-primary hover:bg-primary/90 text-white rounded-xl text-sm font-normal transition-all active:scale-95 shadow-xl shadow-primary/10 tracking-tight border border-primary/30 flex items-center justify-center gap-2 disabled:opacity-50"
|
||||||
aria-label="Export audit trail as CSV"
|
aria-label="Export audit trail as CSV"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? <Loader2 size={14} className="animate-spin" /> : <FileText size={14} />}
|
||||||
<Loader2 size={16} className="animate-spin" />
|
CSV
|
||||||
) : (
|
|
||||||
<FileDown size={16} />
|
|
||||||
)}
|
|
||||||
{isLoading ? "Exporting..." : "Export as CSV"}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleExportAuditTrail("xlsx")}
|
onClick={() => handleExportAuditTrail("xlsx")}
|
||||||
disabled={isLoading}
|
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="px-5 py-1.5 bg-primary hover:bg-primary/90 text-white rounded-xl text-sm font-normal transition-all active:scale-95 shadow-xl shadow-primary/10 tracking-tight border border-primary/30 flex items-center justify-center gap-2 disabled:opacity-50"
|
||||||
aria-label="Export audit trail as Excel"
|
aria-label="Export audit trail as Excel"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? <Loader2 size={14} className="animate-spin" /> : <FileSpreadsheet size={14} />}
|
||||||
<Loader2 size={16} className="animate-spin" />
|
Excel
|
||||||
) : (
|
|
||||||
<FileDown size={16} />
|
|
||||||
)}
|
|
||||||
{isLoading ? "Exporting..." : "Export as Excel"}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Messages */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<Toast
|
<div className="mt-4 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 slide-in-from-top-2 duration-300">
|
||||||
type="error"
|
<div className="w-1.5 h-1.5 bg-rose-500 rounded-full animate-pulse" />
|
||||||
message={`Export failed: ${error}`}
|
<span className="font-normal truncate">Error: {error}</span>
|
||||||
onClose={() => {}}
|
</div>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{successMessage && (
|
|
||||||
<Toast type="success" message={successMessage} onClose={() => {}} />
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default function IdentityManager({
|
|||||||
setEditingUser(user);
|
setEditingUser(user);
|
||||||
setEditUserForm({ username: user.username, password: '', role: user.role });
|
setEditUserForm({ username: user.username, password: '', role: user.role });
|
||||||
}}
|
}}
|
||||||
className="p-2 text-secondary hover:text-primary hover:bg-primary/5 rounded-xl transition-all focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 text-secondary hover:text-primary hover:bg-primary/5 rounded-xl transition-all focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none"
|
||||||
aria-label={`Edit user ${user.username}`}
|
aria-label={`Edit user ${user.username}`}
|
||||||
>
|
>
|
||||||
<Edit2 size={16} />
|
<Edit2 size={16} />
|
||||||
@@ -72,7 +72,7 @@ export default function IdentityManager({
|
|||||||
<button
|
<button
|
||||||
onClick={() => onDeleteUser(user.id, user.username)}
|
onClick={() => onDeleteUser(user.id, user.username)}
|
||||||
data-testid="delete-user-button"
|
data-testid="delete-user-button"
|
||||||
className="p-2 text-secondary hover:text-rose-500 hover:bg-rose-500/5 rounded-xl transition-all focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:outline-none"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 text-secondary hover:text-rose-500 hover:bg-rose-500/5 rounded-xl transition-all focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:outline-none"
|
||||||
aria-label={`Delete user ${user.username}`}
|
aria-label={`Delete user ${user.username}`}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
@@ -91,7 +91,7 @@ export default function IdentityManager({
|
|||||||
<h3 className="text-xl font-normal text-white tracking-tight">Edit Identity</h3>
|
<h3 className="text-xl font-normal text-white tracking-tight">Edit Identity</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingUser(null)}
|
onClick={() => setEditingUser(null)}
|
||||||
className="p-2 text-muted hover:text-white transition-colors focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none rounded"
|
className="p-2 lg:p-3 xl:p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 text-muted hover:text-white transition-colors focus-visible:ring-2 focus-visible:ring-primary focus-visible:outline-none rounded"
|
||||||
aria-label="Close edit dialog"
|
aria-label="Close edit dialog"
|
||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default function LdapManager({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-4 bg-background/40 border border-slate-800/60 rounded-2xl group transition-all hover:border-primary/30">
|
<div className="flex items-center justify-between p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-background/40 border border-slate-800/60 rounded-2xl group transition-all hover:border-primary/30">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"w-8 h-8 rounded-xl flex items-center justify-center transition-all shadow-inner",
|
"w-8 h-8 rounded-xl flex items-center justify-center transition-all shadow-inner",
|
||||||
@@ -115,7 +115,7 @@ export default function LdapManager({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-4 bg-background/40 border border-slate-800/60 rounded-2xl group transition-all hover:border-primary/30">
|
<div className="flex items-center justify-between p-4 lg:p-5 xl:p-6 lg:p-8 xl:p-10 bg-background/40 border border-slate-800/60 rounded-2xl group transition-all hover:border-primary/30">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"w-8 h-8 rounded-xl flex items-center justify-center transition-all shadow-inner",
|
"w-8 h-8 rounded-xl flex items-center justify-center transition-all shadow-inner",
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function useExport(): UseExportReturn {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axiosInstance.get(
|
const response = await axiosInstance.get(
|
||||||
`/admin/db/export?format=${format}&type=inventory`,
|
`/admin/reports/export?format=${format}&type=inventory`,
|
||||||
{
|
{
|
||||||
responseType: "blob"
|
responseType: "blob"
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ export function useExport(): UseExportReturn {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axiosInstance.get(
|
const response = await axiosInstance.get(
|
||||||
`/admin/db/export?format=${format}&type=audit`,
|
`/admin/reports/export?format=${format}&type=audit`,
|
||||||
{
|
{
|
||||||
responseType: "blob"
|
responseType: "blob"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,12 +210,25 @@ def main():
|
|||||||
|
|
||||||
# Prepare environment variables for Docker Compose
|
# Prepare environment variables for Docker Compose
|
||||||
# We map YAML values to the environment variables expected by docker-compose.yml
|
# We map YAML values to the environment variables expected by docker-compose.yml
|
||||||
|
server_ip = network_cfg.get("application", {}).get("server_ip", "localhost")
|
||||||
|
ssl_enabled = network_cfg.get("ssl", {}).get("ssl_enabled", False)
|
||||||
|
|
||||||
|
# Dynamically construct API URL from master settings
|
||||||
|
if ssl_enabled:
|
||||||
|
api_url = f"https://{server_ip}:{ports_to_check['Backend HTTPS']}"
|
||||||
|
else:
|
||||||
|
api_url = f"http://{server_ip}:{ports_to_check['Backend HTTP']}"
|
||||||
|
|
||||||
|
logger.info(f"Dynamically injecting API URL into Docker: {api_url}")
|
||||||
|
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env["BACKEND_PORT"] = str(ports_to_check["Backend HTTP"])
|
env["BACKEND_PORT"] = str(ports_to_check["Backend HTTP"])
|
||||||
env["FRONTEND_PORT"] = str(ports_to_check["Frontend HTTP"])
|
env["FRONTEND_PORT"] = str(ports_to_check["Frontend HTTP"])
|
||||||
env["BACKEND_SSL_PORT"] = str(ports_to_check["Backend HTTPS"])
|
env["BACKEND_SSL_PORT"] = str(ports_to_check["Backend HTTPS"])
|
||||||
env["FRONTEND_SSL_PORT"] = str(ports_to_check["Frontend HTTPS"])
|
env["FRONTEND_SSL_PORT"] = str(ports_to_check["Frontend HTTPS"])
|
||||||
env["JWT_SECRET_KEY"] = jwt_secret or "change_me_in_production"
|
env["JWT_SECRET_KEY"] = jwt_secret or "change_me_in_production"
|
||||||
|
env["NEXT_PUBLIC_API_URL"] = api_url
|
||||||
|
env["SERVER_IP"] = server_ip
|
||||||
|
|
||||||
# 8. Docker Compose Build
|
# 8. Docker Compose Build
|
||||||
logger.info("Step 8/12: Building/Pulling Docker images...")
|
logger.info("Step 8/12: Building/Pulling Docker images...")
|
||||||
|
|||||||
@@ -64,6 +64,37 @@ def load_yaml(file_path: str) -> Dict[str, Any]:
|
|||||||
logger.error(f"Failed to parse {file_path}: {e}")
|
logger.error(f"Failed to parse {file_path}: {e}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
# PID file for tracking processes
|
||||||
|
PID_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".standalone.pid")
|
||||||
|
|
||||||
|
def get_running_pids() -> List[int]:
|
||||||
|
if not os.path.exists(PID_FILE):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
with open(PID_FILE, 'r') as f:
|
||||||
|
return [int(line.strip()) for line in f if line.strip()]
|
||||||
|
except:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def save_pids(pids: List[int]):
|
||||||
|
with open(PID_FILE, 'w') as f:
|
||||||
|
for pid in pids:
|
||||||
|
f.write(f"{pid}\n")
|
||||||
|
|
||||||
|
def clear_pids():
|
||||||
|
if os.path.exists(PID_FILE):
|
||||||
|
try:
|
||||||
|
os.remove(PID_FILE)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def is_pid_running(pid: int) -> bool:
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
def is_port_in_use(port: int) -> bool:
|
def is_port_in_use(port: int) -> bool:
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
return s.connect_ex(('localhost', port)) == 0
|
return s.connect_ex(('localhost', port)) == 0
|
||||||
@@ -76,7 +107,7 @@ def log_stream(stream, prefix, color):
|
|||||||
print(f"{color}[{prefix}]{Colors.NC} {line.strip()}")
|
print(f"{color}[{prefix}]{Colors.NC} {line.strip()}")
|
||||||
stream.close()
|
stream.close()
|
||||||
|
|
||||||
def start_backend(backend_port: int, backend_cfg: Dict[str, Any]):
|
def start_backend(backend_port: int, backend_cfg: Dict[str, Any], background: bool = False):
|
||||||
logger.info(f"Starting Backend on port {backend_port}...")
|
logger.info(f"Starting Backend on port {backend_port}...")
|
||||||
|
|
||||||
app_cfg = backend_cfg.get("application", {})
|
app_cfg = backend_cfg.get("application", {})
|
||||||
@@ -96,73 +127,231 @@ def start_backend(backend_port: int, backend_cfg: Dict[str, Any]):
|
|||||||
"--port", str(backend_port)
|
"--port", str(backend_port)
|
||||||
]
|
]
|
||||||
|
|
||||||
# Add --reload if in dev mode (detectable via environment or flag,
|
log_file = None
|
||||||
# but the script is for 'run_standalone' which might be used for prod too)
|
if background:
|
||||||
# For now, let's keep it simple.
|
log_path = os.path.join(env["LOGS_DIR"], "backend_standalone.log")
|
||||||
|
log_file = open(log_path, "a")
|
||||||
|
logger.info(f"Backend logs redirected to {log_path}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
p = subprocess.Popen(
|
p = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdout=subprocess.PIPE,
|
stdout=log_file if background else subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=log_file if background else subprocess.PIPE,
|
||||||
text=True,
|
text=True,
|
||||||
env=env,
|
env=env,
|
||||||
bufsize=1,
|
bufsize=1,
|
||||||
universal_newlines=True
|
universal_newlines=True,
|
||||||
|
start_new_session=background
|
||||||
)
|
)
|
||||||
processes.append(p)
|
processes.append(p)
|
||||||
|
|
||||||
threading.Thread(target=log_stream, args=(p.stdout, "BACKEND", Colors.GREEN), daemon=True).start()
|
if not background:
|
||||||
threading.Thread(target=log_stream, args=(p.stderr, "BACKEND", Colors.GREEN), daemon=True).start()
|
threading.Thread(target=log_stream, args=(p.stdout, "BACKEND", Colors.GREEN), daemon=True).start()
|
||||||
|
threading.Thread(target=log_stream, args=(p.stderr, "BACKEND", Colors.GREEN), daemon=True).start()
|
||||||
|
|
||||||
return p
|
return p
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logger.error("uvicorn not found. Please install it with: pip install uvicorn")
|
logger.error("uvicorn not found. Please install it with: pip install uvicorn")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
def start_frontend(frontend_port: int, frontend_cfg: Dict[str, Any]):
|
def start_frontend(frontend_port: int, frontend_cfg: Dict[str, Any], network_cfg: Dict[str, Any], background: bool = False):
|
||||||
logger.info(f"Starting Frontend on port {frontend_port}...")
|
logger.info(f"Starting Frontend on port {frontend_port}...")
|
||||||
|
|
||||||
api_cfg = frontend_cfg.get("api", {})
|
server_ip = network_cfg.get("application", {}).get("server_ip", "localhost")
|
||||||
env = os.environ.copy()
|
ssl_enabled = network_cfg.get("ssl", {}).get("ssl_enabled", False)
|
||||||
env["NEXT_PUBLIC_API_URL"] = api_cfg.get("backend_url", "http://localhost:8916")
|
ports = network_cfg.get("ports", {})
|
||||||
env["PORT"] = str(frontend_port)
|
|
||||||
|
|
||||||
# Check if we should use 'npm run dev' or 'node server.js'
|
# Dynamically construct API URL from master settings
|
||||||
# For standalone, we might prefer 'npm run start' if built, or 'npm run dev'
|
if ssl_enabled:
|
||||||
# Bash script used 'npm run start'
|
back_ssl_port = ports.get("backend_ssl_port", 8918)
|
||||||
|
api_url = f"https://{server_ip}:{back_ssl_port}"
|
||||||
|
else:
|
||||||
|
back_port = ports.get("backend_port", 8916)
|
||||||
|
api_url = f"http://{server_ip}:{back_port}"
|
||||||
|
|
||||||
|
logger.info(f"Dynamically injecting API URL: {api_url}")
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["NEXT_PUBLIC_API_URL"] = api_url
|
||||||
|
env["PORT"] = str(frontend_port)
|
||||||
|
|
||||||
cmd = ["npm", "run", "dev"]
|
cmd = ["npm", "run", "dev"]
|
||||||
if os.path.exists("frontend/server.js"):
|
if os.path.exists("frontend/server.js"):
|
||||||
# If built for production
|
|
||||||
cmd = ["node", "frontend/server.js"]
|
cmd = ["node", "frontend/server.js"]
|
||||||
|
|
||||||
|
log_file = None
|
||||||
|
if background:
|
||||||
|
logs_dir = os.environ.get("LOGS_DIR", "./logs")
|
||||||
|
log_path = os.path.join(logs_dir, "frontend_standalone.log")
|
||||||
|
log_file = open(log_path, "a")
|
||||||
|
logger.info(f"Frontend logs redirected to {log_path}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
p = subprocess.Popen(
|
p = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
cwd="frontend" if os.path.isdir("frontend") else None,
|
cwd="frontend" if os.path.isdir("frontend") else None,
|
||||||
stdout=subprocess.PIPE,
|
stdout=log_file if background else subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=log_file if background else subprocess.PIPE,
|
||||||
text=True,
|
text=True,
|
||||||
env=env,
|
env=env,
|
||||||
bufsize=1,
|
bufsize=1,
|
||||||
universal_newlines=True
|
universal_newlines=True,
|
||||||
|
start_new_session=background
|
||||||
)
|
)
|
||||||
processes.append(p)
|
processes.append(p)
|
||||||
|
|
||||||
threading.Thread(target=log_stream, args=(p.stdout, "FRONTEND", Colors.MAGENTA), daemon=True).start()
|
if not background:
|
||||||
threading.Thread(target=log_stream, args=(p.stderr, "FRONTEND", Colors.MAGENTA), daemon=True).start()
|
threading.Thread(target=log_stream, args=(p.stdout, "FRONTEND", Colors.MAGENTA), daemon=True).start()
|
||||||
|
threading.Thread(target=log_stream, args=(p.stderr, "FRONTEND", Colors.MAGENTA), daemon=True).start()
|
||||||
|
|
||||||
return p
|
return p
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logger.error("npm or node not found. Please install Node.js")
|
logger.error("npm or node not found. Please install Node.js")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
def start_proxy(network_cfg: Dict[str, Any], background: bool = False):
|
||||||
|
ssl_cfg = network_cfg.get("ssl", {})
|
||||||
|
if not ssl_cfg.get("ssl_enabled", False):
|
||||||
|
return None
|
||||||
|
|
||||||
|
caddyfile = "Caddyfile.standalone"
|
||||||
|
if not os.path.exists(caddyfile):
|
||||||
|
logger.warning(f"{caddyfile} not found. SSL proxy will not start.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.info("Starting SSL Proxy (Caddy)...")
|
||||||
|
|
||||||
|
# Check if caddy is installed
|
||||||
|
try:
|
||||||
|
subprocess.run(["caddy", "version"], capture_output=True, check=True)
|
||||||
|
except:
|
||||||
|
logger.error("Caddy not found. Please install Caddy to use SSL proxy.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
cmd = ["caddy", "run", "--config", caddyfile, "--adapter", "caddyfile"]
|
||||||
|
|
||||||
|
log_file = None
|
||||||
|
if background:
|
||||||
|
logs_dir = os.environ.get("LOGS_DIR", "./logs")
|
||||||
|
log_path = os.path.join(logs_dir, "proxy_standalone.log")
|
||||||
|
log_file = open(log_path, "a")
|
||||||
|
logger.info(f"Proxy logs redirected to {log_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
p = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=log_file if background else subprocess.PIPE,
|
||||||
|
stderr=log_file if background else subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
bufsize=1,
|
||||||
|
universal_newlines=True,
|
||||||
|
start_new_session=background
|
||||||
|
)
|
||||||
|
processes.append(p)
|
||||||
|
|
||||||
|
if not background:
|
||||||
|
threading.Thread(target=log_stream, args=(p.stdout, "PROXY", Colors.CYAN), daemon=True).start()
|
||||||
|
threading.Thread(target=log_stream, args=(p.stderr, "PROXY", Colors.CYAN), daemon=True).start()
|
||||||
|
|
||||||
|
return p
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start Caddy: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def action_status(backend_port: int, frontend_port: int, network_cfg: Dict[str, Any]):
|
||||||
|
logger.info("Checking status...")
|
||||||
|
backend_up = is_port_in_use(backend_port)
|
||||||
|
frontend_up = is_port_in_use(frontend_port)
|
||||||
|
|
||||||
|
ssl_cfg = network_cfg.get("ssl", {})
|
||||||
|
ssl_enabled = ssl_cfg.get("ssl_enabled", False)
|
||||||
|
proxy_up = False
|
||||||
|
if ssl_enabled:
|
||||||
|
proxy_up = is_port_in_use(network_cfg.get("ports", {}).get("frontend_ssl_port", 8919))
|
||||||
|
|
||||||
|
pids = get_running_pids()
|
||||||
|
running_pids = [pid for pid in pids if is_pid_running(pid)]
|
||||||
|
|
||||||
|
if backend_up or frontend_up or running_pids:
|
||||||
|
print(f"{Colors.GREEN}● aInventory Standalone is RUNNING{Colors.NC}")
|
||||||
|
print(f" - Backend (Port {backend_port}): {'UP' if backend_up else 'DOWN'}")
|
||||||
|
print(f" - Frontend (Port {frontend_port}): {'UP' if frontend_up else 'DOWN'}")
|
||||||
|
if ssl_enabled:
|
||||||
|
print(f" - SSL Proxy: {'UP' if proxy_up else 'DOWN'}")
|
||||||
|
if running_pids:
|
||||||
|
print(f" - Active PIDs: {', '.join(map(str, running_pids))}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"{Colors.RED}○ aInventory Standalone is STOPPED{Colors.NC}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def action_stop(backend_port: int, frontend_port: int, network_cfg: Dict[str, Any]):
|
||||||
|
pids = get_running_pids()
|
||||||
|
|
||||||
|
if pids:
|
||||||
|
logger.info(f"Stopping processes with PIDs: {', '.join(map(str, pids))}...")
|
||||||
|
for pid in pids:
|
||||||
|
if is_pid_running(pid):
|
||||||
|
try:
|
||||||
|
# Kill the whole process group if possible
|
||||||
|
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
||||||
|
except:
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
for pid in pids:
|
||||||
|
if is_pid_running(pid):
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(pid), signal.SIGKILL)
|
||||||
|
except:
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGKILL)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
clear_pids()
|
||||||
|
|
||||||
|
# Port-based cleanup (handles orphaned children like npm -> node)
|
||||||
|
cleanup_ports = [backend_port, frontend_port]
|
||||||
|
ports_cfg = network_cfg.get("ports", {})
|
||||||
|
if network_cfg.get("ssl", {}).get("ssl_enabled"):
|
||||||
|
cleanup_ports.append(ports_cfg.get("backend_ssl_port", 8918))
|
||||||
|
cleanup_ports.append(ports_cfg.get("frontend_ssl_port", 8919))
|
||||||
|
|
||||||
|
for port in cleanup_ports:
|
||||||
|
if is_port_in_use(port):
|
||||||
|
logger.info(f"Cleaning up orphaned process on port {port}...")
|
||||||
|
try:
|
||||||
|
# Use fuser-like logic to find the PID on that port
|
||||||
|
# Note: This is Linux specific
|
||||||
|
cmd = ["lsof", "-t", f"-i:{port}"]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
orphans = [int(p) for p in result.stdout.splitlines() if p.strip()]
|
||||||
|
for o_pid in orphans:
|
||||||
|
if o_pid != os.getpid():
|
||||||
|
logger.info(f" - Killing orphan PID {o_pid}")
|
||||||
|
try:
|
||||||
|
os.kill(o_pid, signal.SIGKILL)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not perform port-based cleanup: {e}")
|
||||||
|
|
||||||
|
logger.info("Stopped.")
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="TFM aInventory Standalone Launcher")
|
parser = argparse.ArgumentParser(description="TFM aInventory Standalone Launcher")
|
||||||
|
parser.add_argument("action", nargs="?", choices=["start", "stop", "restart", "status", "run"], default="run",
|
||||||
|
help="Action to perform: run (foreground), start (background), stop, restart, status")
|
||||||
|
parser.add_argument("--no-proxy", action="store_true", help="Disable SSL proxy even if enabled in config")
|
||||||
group = parser.add_mutually_exclusive_group()
|
group = parser.add_mutually_exclusive_group()
|
||||||
group.add_argument("--backend-only", action="store_true", help="Start only the backend")
|
group.add_argument("--backend-only", action="store_true", help="Operation on backend only")
|
||||||
group.add_argument("--frontend-only", action="store_true", help="Start only the frontend")
|
group.add_argument("--frontend-only", action="store_true", help="Operation on frontend only")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -174,43 +363,71 @@ def main():
|
|||||||
ports_cfg = network_cfg.get("ports", {})
|
ports_cfg = network_cfg.get("ports", {})
|
||||||
backend_port = ports_cfg.get("backend_port", 8916)
|
backend_port = ports_cfg.get("backend_port", 8916)
|
||||||
frontend_port = ports_cfg.get("frontend_port", 8917)
|
frontend_port = ports_cfg.get("frontend_port", 8917)
|
||||||
|
|
||||||
if not args.frontend_only:
|
if args.action == "status":
|
||||||
if is_port_in_use(backend_port):
|
action_status(backend_port, frontend_port, network_cfg)
|
||||||
logger.error(f"Port {backend_port} is already in use. Cannot start backend.")
|
return
|
||||||
if not args.backend_only:
|
|
||||||
# If we are supposed to start both, we fail.
|
if args.action == "stop":
|
||||||
sys.exit(1)
|
action_stop(backend_port, frontend_port, network_cfg)
|
||||||
else:
|
return
|
||||||
start_backend(backend_port, backend_cfg)
|
|
||||||
|
if args.action == "restart":
|
||||||
if not args.backend_only:
|
action_stop(backend_port, frontend_port, network_cfg)
|
||||||
if is_port_in_use(frontend_port):
|
# Fall through to start/run logic
|
||||||
logger.error(f"Port {frontend_port} is already in use. Cannot start frontend.")
|
args.action = "start" if args.action == "restart" else args.action
|
||||||
if not args.frontend_only:
|
|
||||||
sys.exit(1)
|
if args.action in ["start", "run"]:
|
||||||
else:
|
background = (args.action == "start")
|
||||||
start_frontend(frontend_port, frontend_cfg)
|
|
||||||
|
|
||||||
if not processes:
|
|
||||||
logger.error("No processes started. Check configuration and port availability.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
logger.info("All services started. Press Ctrl+C to stop.")
|
if not args.frontend_only:
|
||||||
|
if is_port_in_use(backend_port):
|
||||||
# Monitor processes
|
logger.error(f"Port {backend_port} is already in use. Backend cannot start.")
|
||||||
try:
|
if not args.backend_only and args.action == "run":
|
||||||
while not shutdown_event.is_set():
|
sys.exit(1)
|
||||||
time.sleep(1)
|
else:
|
||||||
for p in processes:
|
start_backend(backend_port, backend_cfg, background=background)
|
||||||
if p.poll() is not None:
|
|
||||||
logger.error(f"Process {p.pid} exited with code {p.returncode}")
|
if not args.backend_only:
|
||||||
shutdown_event.set()
|
if is_port_in_use(frontend_port):
|
||||||
break
|
logger.error(f"Port {frontend_port} is already in use. Frontend cannot start.")
|
||||||
except KeyboardInterrupt:
|
if not args.frontend_only and args.action == "run":
|
||||||
pass
|
sys.exit(1)
|
||||||
finally:
|
else:
|
||||||
signal_handler(None, None)
|
start_frontend(frontend_port, frontend_cfg, network_cfg, background=background)
|
||||||
|
|
||||||
|
# Start SSL Proxy if enabled
|
||||||
|
if not args.no_proxy and not args.backend_only and not args.frontend_only:
|
||||||
|
start_proxy(network_cfg, background=background)
|
||||||
|
|
||||||
|
if not processes:
|
||||||
|
logger.error("No processes started. Check configuration and port availability.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Save PIDs
|
||||||
|
save_pids([p.pid for p in processes])
|
||||||
|
|
||||||
|
if background:
|
||||||
|
logger.info(f"All services started in BACKGROUND. PIDs: {', '.join(str(p.pid) for p in processes)}")
|
||||||
|
logger.info(f"Use 'python3 {sys.argv[0]} status' to check state.")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("All services started in FOREGROUND. Press Ctrl+C to stop.")
|
||||||
|
|
||||||
|
# Monitor processes
|
||||||
|
try:
|
||||||
|
while not shutdown_event.is_set():
|
||||||
|
time.sleep(1)
|
||||||
|
for p in processes:
|
||||||
|
if p.poll() is not None:
|
||||||
|
logger.error(f"Process {p.pid} exited with code {p.returncode}")
|
||||||
|
shutdown_event.set()
|
||||||
|
break
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
signal_handler(None, None)
|
||||||
|
clear_pids()
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -83,10 +83,9 @@ def main():
|
|||||||
|
|
||||||
# 5. Create Production Bundle
|
# 5. Create Production Bundle
|
||||||
print("📦 Generating production bundle...")
|
print("📦 Generating production bundle...")
|
||||||
run_command(['./export_prod.sh'])
|
run_command(['python3', 'scripts/export_prod.py'])
|
||||||
|
|
||||||
print(f"Successfully saved version {new_version}, created branch {branch_name}, updated 'master', and generated production ZIP.")
|
print(f"Successfully saved version {new_version}, created branch {branch_name}, updated 'master', and generated production archive in backups/.")
|
||||||
print("Verification: check for the .zip file in the root.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -39,10 +39,10 @@ deactivate nondestructive
|
|||||||
if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then
|
if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then
|
||||||
# transform D:\path\to\venv to /d/path/to/venv on MSYS
|
# transform D:\path\to\venv to /d/path/to/venv on MSYS
|
||||||
# and to /cygdrive/d/path/to/venv on Cygwin
|
# and to /cygdrive/d/path/to/venv on Cygwin
|
||||||
export VIRTUAL_ENV=$(cygpath /tmp/image-system-phase1/venv)
|
export VIRTUAL_ENV=$(cygpath /data/programare_AI/tfm_ainventory/venv)
|
||||||
else
|
else
|
||||||
# use the path as-is
|
# use the path as-is
|
||||||
export VIRTUAL_ENV=/tmp/image-system-phase1/venv
|
export VIRTUAL_ENV=/data/programare_AI/tfm_ainventory/venv
|
||||||
fi
|
fi
|
||||||
|
|
||||||
_OLD_VIRTUAL_PATH="$PATH"
|
_OLD_VIRTUAL_PATH="$PATH"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PA
|
|||||||
# Unset irrelevant variables.
|
# Unset irrelevant variables.
|
||||||
deactivate nondestructive
|
deactivate nondestructive
|
||||||
|
|
||||||
setenv VIRTUAL_ENV /tmp/image-system-phase1/venv
|
setenv VIRTUAL_ENV /data/programare_AI/tfm_ainventory/venv
|
||||||
|
|
||||||
set _OLD_VIRTUAL_PATH="$PATH"
|
set _OLD_VIRTUAL_PATH="$PATH"
|
||||||
setenv PATH "$VIRTUAL_ENV/"bin":$PATH"
|
setenv PATH "$VIRTUAL_ENV/"bin":$PATH"
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ end
|
|||||||
# Unset irrelevant variables.
|
# Unset irrelevant variables.
|
||||||
deactivate nondestructive
|
deactivate nondestructive
|
||||||
|
|
||||||
set -gx VIRTUAL_ENV /tmp/image-system-phase1/venv
|
set -gx VIRTUAL_ENV /data/programare_AI/tfm_ainventory/venv
|
||||||
|
|
||||||
set -gx _OLD_VIRTUAL_PATH $PATH
|
set -gx _OLD_VIRTUAL_PATH $PATH
|
||||||
set -gx PATH "$VIRTUAL_ENV/"bin $PATH
|
set -gx PATH "$VIRTUAL_ENV/"bin $PATH
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
#!/tmp/image-system-phase1/venv/bin/python3
|
#!/data/programare_AI/tfm_ainventory/venv/bin/python3
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from coverage.cmdline import main
|
from coverage.cmdline import main
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
sys.argv[0] = sys.argv[0].removesuffix('.exe')
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
#!/tmp/image-system-phase1/venv/bin/python3
|
#!/data/programare_AI/tfm_ainventory/venv/bin/python3
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from coverage.cmdline import main_deprecated
|
from coverage.cmdline import main_deprecated
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
sys.argv[0] = sys.argv[0].removesuffix('.exe')
|
||||||
sys.exit(main_deprecated())
|
sys.exit(main_deprecated())
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
#!/tmp/image-system-phase1/venv/bin/python3
|
#!/data/programare_AI/tfm_ainventory/venv/bin/python3
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from coverage.cmdline import main_deprecated
|
from coverage.cmdline import main_deprecated
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
sys.argv[0] = sys.argv[0].removesuffix('.exe')
|
||||||
sys.exit(main_deprecated())
|
sys.exit(main_deprecated())
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
#!/tmp/image-system-phase1/venv/bin/python3
|
#!/data/programare_AI/tfm_ainventory/venv/bin/python3
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from distro.distro import main
|
from distro.distro import main
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
sys.argv[0] = sys.argv[0].removesuffix('.exe')
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
#!/tmp/image-system-phase1/venv/bin/python3
|
#!/data/programare_AI/tfm_ainventory/venv/bin/python3
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from dotenv.__main__ import cli
|
from dotenv.__main__ import cli
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
sys.argv[0] = sys.argv[0].removesuffix('.exe')
|
||||||
sys.exit(cli())
|
sys.exit(cli())
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
#!/tmp/image-system-phase1/venv/bin/python3
|
#!/data/programare_AI/tfm_ainventory/venv/bin/python3
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from numpy.f2py.f2py2e import main
|
from numpy.f2py.f2py2e import main
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
sys.argv[0] = sys.argv[0].removesuffix('.exe')
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
#!/tmp/image-system-phase1/venv/bin/python3
|
#!/data/programare_AI/tfm_ainventory/venv/bin/python3
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from fastapi.cli import main
|
from fastapi.cli import main
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
sys.argv[0] = sys.argv[0].removesuffix('.exe')
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
#!/tmp/image-system-phase1/venv/bin/python3
|
#!/data/programare_AI/tfm_ainventory/venv/bin/python3
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from httpx import main
|
from httpx import main
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
sys.argv[0] = sys.argv[0].removesuffix('.exe')
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
#!/tmp/image-system-phase1/venv/bin/python3
|
#!/data/programare_AI/tfm_ainventory/venv/bin/python3
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from charset_normalizer.cli import cli_detect
|
from charset_normalizer.cli import cli_detect
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
sys.argv[0] = sys.argv[0].removesuffix('.exe')
|
||||||
sys.exit(cli_detect())
|
sys.exit(cli_detect())
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
#!/tmp/image-system-phase1/venv/bin/python3
|
#!/data/programare_AI/tfm_ainventory/venv/bin/python3
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from numpy._configtool import main
|
from numpy._configtool import main
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
sys.argv[0] = sys.argv[0].removesuffix('.exe')
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/tmp/image-system-phase1/venv/bin/python3
|
#!/data/programare_AI/tfm_ainventory/venv/bin/python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/tmp/image-system-phase1/venv/bin/python3
|
#!/data/programare_AI/tfm_ainventory/venv/bin/python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
#!/tmp/image-system-phase1/venv/bin/python3
|
#!/data/programare_AI/tfm_ainventory/venv/bin/python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
#!/tmp/image-system-phase1/venv/bin/python3
|
#!/data/programare_AI/tfm_ainventory/venv/bin/python3
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from pytest import console_main
|
from pytest import console_main
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
sys.argv[0] = sys.argv[0].removesuffix('.exe')
|
||||||
sys.exit(console_main())
|
sys.exit(console_main())
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
#!/tmp/image-system-phase1/venv/bin/python3
|
#!/data/programare_AI/tfm_ainventory/venv/bin/python3
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from pygments.cmdline import main
|
from pygments.cmdline import main
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
sys.argv[0] = sys.argv[0].removesuffix('.exe')
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
#!/tmp/image-system-phase1/venv/bin/python3
|
#!/data/programare_AI/tfm_ainventory/venv/bin/python3
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from rsa.cli import decrypt
|
from rsa.cli import decrypt
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
sys.argv[0] = sys.argv[0].removesuffix('.exe')
|
||||||
sys.exit(decrypt())
|
sys.exit(decrypt())
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
#!/tmp/image-system-phase1/venv/bin/python3
|
#!/data/programare_AI/tfm_ainventory/venv/bin/python3
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from rsa.cli import encrypt
|
from rsa.cli import encrypt
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
sys.argv[0] = sys.argv[0].removesuffix('.exe')
|
||||||
sys.exit(encrypt())
|
sys.exit(encrypt())
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
#!/tmp/image-system-phase1/venv/bin/python3
|
#!/data/programare_AI/tfm_ainventory/venv/bin/python3
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from rsa.cli import keygen
|
from rsa.cli import keygen
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
sys.argv[0] = sys.argv[0].removesuffix('.exe')
|
||||||
sys.exit(keygen())
|
sys.exit(keygen())
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
#!/tmp/image-system-phase1/venv/bin/python3
|
#!/data/programare_AI/tfm_ainventory/venv/bin/python3
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from rsa.util import private_to_public
|
from rsa.util import private_to_public
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
sys.argv[0] = sys.argv[0].removesuffix('.exe')
|
||||||
sys.exit(private_to_public())
|
sys.exit(private_to_public())
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
#!/tmp/image-system-phase1/venv/bin/python3
|
#!/data/programare_AI/tfm_ainventory/venv/bin/python3
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from rsa.cli import sign
|
from rsa.cli import sign
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
sys.argv[0] = sys.argv[0].removesuffix('.exe')
|
||||||
sys.exit(sign())
|
sys.exit(sign())
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
#!/tmp/image-system-phase1/venv/bin/python3
|
#!/data/programare_AI/tfm_ainventory/venv/bin/python3
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from rsa.cli import verify
|
from rsa.cli import verify
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
sys.argv[0] = sys.argv[0].removesuffix('.exe')
|
||||||
sys.exit(verify())
|
sys.exit(verify())
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
#!/tmp/image-system-phase1/venv/bin/python3
|
#!/data/programare_AI/tfm_ainventory/venv/bin/python3
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from pytest import console_main
|
from pytest import console_main
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
sys.argv[0] = sys.argv[0].removesuffix('.exe')
|
||||||
sys.exit(console_main())
|
sys.exit(console_main())
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
#!/tmp/image-system-phase1/venv/bin/python3
|
#!/data/programare_AI/tfm_ainventory/venv/bin/python3
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from uvicorn.main import main
|
from uvicorn.main import main
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
sys.argv[0] = sys.argv[0].removesuffix('.exe')
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
#!/tmp/image-system-phase1/venv/bin/python3
|
#!/data/programare_AI/tfm_ainventory/venv/bin/python3
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from watchfiles.cli import cli
|
from watchfiles.cli import cli
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
sys.argv[0] = sys.argv[0].removesuffix('.exe')
|
||||||
sys.exit(cli())
|
sys.exit(cli())
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
#!/tmp/image-system-phase1/venv/bin/python3
|
#!/data/programare_AI/tfm_ainventory/venv/bin/python3
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from websockets.cli import main
|
from websockets.cli import main
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
sys.argv[0] = sys.argv[0].removesuffix('.exe')
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
A. HISTORY OF THE SOFTWARE
|
||||||
|
==========================
|
||||||
|
|
||||||
|
Python was created in the early 1990s by Guido van Rossum at Stichting
|
||||||
|
Mathematisch Centrum (CWI, see https://www.cwi.nl) in the Netherlands
|
||||||
|
as a successor of a language called ABC. Guido remains Python's
|
||||||
|
principal author, although it includes many contributions from others.
|
||||||
|
|
||||||
|
In 1995, Guido continued his work on Python at the Corporation for
|
||||||
|
National Research Initiatives (CNRI, see https://www.cnri.reston.va.us)
|
||||||
|
in Reston, Virginia where he released several versions of the
|
||||||
|
software.
|
||||||
|
|
||||||
|
In May 2000, Guido and the Python core development team moved to
|
||||||
|
BeOpen.com to form the BeOpen PythonLabs team. In October of the same
|
||||||
|
year, the PythonLabs team moved to Digital Creations, which became
|
||||||
|
Zope Corporation. In 2001, the Python Software Foundation (PSF, see
|
||||||
|
https://www.python.org/psf/) was formed, a non-profit organization
|
||||||
|
created specifically to own Python-related Intellectual Property.
|
||||||
|
Zope Corporation was a sponsoring member of the PSF.
|
||||||
|
|
||||||
|
All Python releases are Open Source (see https://opensource.org for
|
||||||
|
the Open Source Definition). Historically, most, but not all, Python
|
||||||
|
releases have also been GPL-compatible; the table below summarizes
|
||||||
|
the various releases.
|
||||||
|
|
||||||
|
Release Derived Year Owner GPL-
|
||||||
|
from compatible? (1)
|
||||||
|
|
||||||
|
0.9.0 thru 1.2 1991-1995 CWI yes
|
||||||
|
1.3 thru 1.5.2 1.2 1995-1999 CNRI yes
|
||||||
|
1.6 1.5.2 2000 CNRI no
|
||||||
|
2.0 1.6 2000 BeOpen.com no
|
||||||
|
1.6.1 1.6 2001 CNRI yes (2)
|
||||||
|
2.1 2.0+1.6.1 2001 PSF no
|
||||||
|
2.0.1 2.0+1.6.1 2001 PSF yes
|
||||||
|
2.1.1 2.1+2.0.1 2001 PSF yes
|
||||||
|
2.1.2 2.1.1 2002 PSF yes
|
||||||
|
2.1.3 2.1.2 2002 PSF yes
|
||||||
|
2.2 and above 2.1.1 2001-now PSF yes
|
||||||
|
|
||||||
|
Footnotes:
|
||||||
|
|
||||||
|
(1) GPL-compatible doesn't mean that we're distributing Python under
|
||||||
|
the GPL. All Python licenses, unlike the GPL, let you distribute
|
||||||
|
a modified version without making your changes open source. The
|
||||||
|
GPL-compatible licenses make it possible to combine Python with
|
||||||
|
other software that is released under the GPL; the others don't.
|
||||||
|
|
||||||
|
(2) According to Richard Stallman, 1.6.1 is not GPL-compatible,
|
||||||
|
because its license has a choice of law clause. According to
|
||||||
|
CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1
|
||||||
|
is "not incompatible" with the GPL.
|
||||||
|
|
||||||
|
Thanks to the many outside volunteers who have worked under Guido's
|
||||||
|
direction to make these releases possible.
|
||||||
|
|
||||||
|
|
||||||
|
B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON
|
||||||
|
===============================================================
|
||||||
|
|
||||||
|
Python software and documentation are licensed under the
|
||||||
|
Python Software Foundation License Version 2.
|
||||||
|
|
||||||
|
Starting with Python 3.8.6, examples, recipes, and other code in
|
||||||
|
the documentation are dual licensed under the PSF License Version 2
|
||||||
|
and the Zero-Clause BSD license.
|
||||||
|
|
||||||
|
Some software incorporated into Python is under different licenses.
|
||||||
|
The licenses are listed with code falling under that license.
|
||||||
|
|
||||||
|
|
||||||
|
PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2
|
||||||
|
--------------------------------------------
|
||||||
|
|
||||||
|
1. This LICENSE AGREEMENT is between the Python Software Foundation
|
||||||
|
("PSF"), and the Individual or Organization ("Licensee") accessing and
|
||||||
|
otherwise using this software ("Python") in source or binary form and
|
||||||
|
its associated documentation.
|
||||||
|
|
||||||
|
2. Subject to the terms and conditions of this License Agreement, PSF hereby
|
||||||
|
grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce,
|
||||||
|
analyze, test, perform and/or display publicly, prepare derivative works,
|
||||||
|
distribute, and otherwise use Python alone or in any derivative version,
|
||||||
|
provided, however, that PSF's License Agreement and PSF's notice of copyright,
|
||||||
|
i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010,
|
||||||
|
2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023 Python Software Foundation;
|
||||||
|
All Rights Reserved" are retained in Python alone or in any derivative version
|
||||||
|
prepared by Licensee.
|
||||||
|
|
||||||
|
3. In the event Licensee prepares a derivative work that is based on
|
||||||
|
or incorporates Python or any part thereof, and wants to make
|
||||||
|
the derivative work available to others as provided herein, then
|
||||||
|
Licensee hereby agrees to include in any such work a brief summary of
|
||||||
|
the changes made to Python.
|
||||||
|
|
||||||
|
4. PSF is making Python available to Licensee on an "AS IS"
|
||||||
|
basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
|
||||||
|
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND
|
||||||
|
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
|
||||||
|
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT
|
||||||
|
INFRINGE ANY THIRD PARTY RIGHTS.
|
||||||
|
|
||||||
|
5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
|
||||||
|
FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
|
||||||
|
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON,
|
||||||
|
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
|
||||||
|
|
||||||
|
6. This License Agreement will automatically terminate upon a material
|
||||||
|
breach of its terms and conditions.
|
||||||
|
|
||||||
|
7. Nothing in this License Agreement shall be deemed to create any
|
||||||
|
relationship of agency, partnership, or joint venture between PSF and
|
||||||
|
Licensee. This License Agreement does not grant permission to use PSF
|
||||||
|
trademarks or trade name in a trademark sense to endorse or promote
|
||||||
|
products or services of Licensee, or any third party.
|
||||||
|
|
||||||
|
8. By copying, installing or otherwise using Python, Licensee
|
||||||
|
agrees to be bound by the terms and conditions of this License
|
||||||
|
Agreement.
|
||||||
|
|
||||||
|
|
||||||
|
BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0
|
||||||
|
-------------------------------------------
|
||||||
|
|
||||||
|
BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1
|
||||||
|
|
||||||
|
1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an
|
||||||
|
office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the
|
||||||
|
Individual or Organization ("Licensee") accessing and otherwise using
|
||||||
|
this software in source or binary form and its associated
|
||||||
|
documentation ("the Software").
|
||||||
|
|
||||||
|
2. Subject to the terms and conditions of this BeOpen Python License
|
||||||
|
Agreement, BeOpen hereby grants Licensee a non-exclusive,
|
||||||
|
royalty-free, world-wide license to reproduce, analyze, test, perform
|
||||||
|
and/or display publicly, prepare derivative works, distribute, and
|
||||||
|
otherwise use the Software alone or in any derivative version,
|
||||||
|
provided, however, that the BeOpen Python License is retained in the
|
||||||
|
Software, alone or in any derivative version prepared by Licensee.
|
||||||
|
|
||||||
|
3. BeOpen is making the Software available to Licensee on an "AS IS"
|
||||||
|
basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
|
||||||
|
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND
|
||||||
|
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
|
||||||
|
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT
|
||||||
|
INFRINGE ANY THIRD PARTY RIGHTS.
|
||||||
|
|
||||||
|
4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE
|
||||||
|
SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS
|
||||||
|
AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY
|
||||||
|
DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
|
||||||
|
|
||||||
|
5. This License Agreement will automatically terminate upon a material
|
||||||
|
breach of its terms and conditions.
|
||||||
|
|
||||||
|
6. This License Agreement shall be governed by and interpreted in all
|
||||||
|
respects by the law of the State of California, excluding conflict of
|
||||||
|
law provisions. Nothing in this License Agreement shall be deemed to
|
||||||
|
create any relationship of agency, partnership, or joint venture
|
||||||
|
between BeOpen and Licensee. This License Agreement does not grant
|
||||||
|
permission to use BeOpen trademarks or trade names in a trademark
|
||||||
|
sense to endorse or promote products or services of Licensee, or any
|
||||||
|
third party. As an exception, the "BeOpen Python" logos available at
|
||||||
|
http://www.pythonlabs.com/logos.html may be used according to the
|
||||||
|
permissions granted on that web page.
|
||||||
|
|
||||||
|
7. By copying, installing or otherwise using the software, Licensee
|
||||||
|
agrees to be bound by the terms and conditions of this License
|
||||||
|
Agreement.
|
||||||
|
|
||||||
|
|
||||||
|
CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1
|
||||||
|
---------------------------------------
|
||||||
|
|
||||||
|
1. This LICENSE AGREEMENT is between the Corporation for National
|
||||||
|
Research Initiatives, having an office at 1895 Preston White Drive,
|
||||||
|
Reston, VA 20191 ("CNRI"), and the Individual or Organization
|
||||||
|
("Licensee") accessing and otherwise using Python 1.6.1 software in
|
||||||
|
source or binary form and its associated documentation.
|
||||||
|
|
||||||
|
2. Subject to the terms and conditions of this License Agreement, CNRI
|
||||||
|
hereby grants Licensee a nonexclusive, royalty-free, world-wide
|
||||||
|
license to reproduce, analyze, test, perform and/or display publicly,
|
||||||
|
prepare derivative works, distribute, and otherwise use Python 1.6.1
|
||||||
|
alone or in any derivative version, provided, however, that CNRI's
|
||||||
|
License Agreement and CNRI's notice of copyright, i.e., "Copyright (c)
|
||||||
|
1995-2001 Corporation for National Research Initiatives; All Rights
|
||||||
|
Reserved" are retained in Python 1.6.1 alone or in any derivative
|
||||||
|
version prepared by Licensee. Alternately, in lieu of CNRI's License
|
||||||
|
Agreement, Licensee may substitute the following text (omitting the
|
||||||
|
quotes): "Python 1.6.1 is made available subject to the terms and
|
||||||
|
conditions in CNRI's License Agreement. This Agreement together with
|
||||||
|
Python 1.6.1 may be located on the internet using the following
|
||||||
|
unique, persistent identifier (known as a handle): 1895.22/1013. This
|
||||||
|
Agreement may also be obtained from a proxy server on the internet
|
||||||
|
using the following URL: http://hdl.handle.net/1895.22/1013".
|
||||||
|
|
||||||
|
3. In the event Licensee prepares a derivative work that is based on
|
||||||
|
or incorporates Python 1.6.1 or any part thereof, and wants to make
|
||||||
|
the derivative work available to others as provided herein, then
|
||||||
|
Licensee hereby agrees to include in any such work a brief summary of
|
||||||
|
the changes made to Python 1.6.1.
|
||||||
|
|
||||||
|
4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS"
|
||||||
|
basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
|
||||||
|
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND
|
||||||
|
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
|
||||||
|
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT
|
||||||
|
INFRINGE ANY THIRD PARTY RIGHTS.
|
||||||
|
|
||||||
|
5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
|
||||||
|
1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
|
||||||
|
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1,
|
||||||
|
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
|
||||||
|
|
||||||
|
6. This License Agreement will automatically terminate upon a material
|
||||||
|
breach of its terms and conditions.
|
||||||
|
|
||||||
|
7. This License Agreement shall be governed by the federal
|
||||||
|
intellectual property law of the United States, including without
|
||||||
|
limitation the federal copyright law, and, to the extent such
|
||||||
|
U.S. federal law does not apply, by the law of the Commonwealth of
|
||||||
|
Virginia, excluding Virginia's conflict of law provisions.
|
||||||
|
Notwithstanding the foregoing, with regard to derivative works based
|
||||||
|
on Python 1.6.1 that incorporate non-separable material that was
|
||||||
|
previously distributed under the GNU General Public License (GPL), the
|
||||||
|
law of the Commonwealth of Virginia shall govern this License
|
||||||
|
Agreement only as to issues arising under or with respect to
|
||||||
|
Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this
|
||||||
|
License Agreement shall be deemed to create any relationship of
|
||||||
|
agency, partnership, or joint venture between CNRI and Licensee. This
|
||||||
|
License Agreement does not grant permission to use CNRI trademarks or
|
||||||
|
trade name in a trademark sense to endorse or promote products or
|
||||||
|
services of Licensee, or any third party.
|
||||||
|
|
||||||
|
8. By clicking on the "ACCEPT" button where indicated, or by copying,
|
||||||
|
installing or otherwise using Python 1.6.1, Licensee agrees to be
|
||||||
|
bound by the terms and conditions of this License Agreement.
|
||||||
|
|
||||||
|
ACCEPT
|
||||||
|
|
||||||
|
|
||||||
|
CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2
|
||||||
|
--------------------------------------------------
|
||||||
|
|
||||||
|
Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam,
|
||||||
|
The Netherlands. All rights reserved.
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and distribute this software and its
|
||||||
|
documentation for any purpose and without fee is hereby granted,
|
||||||
|
provided that the above copyright notice appear in all copies and that
|
||||||
|
both that copyright notice and this permission notice appear in
|
||||||
|
supporting documentation, and that the name of Stichting Mathematisch
|
||||||
|
Centrum or CWI not be used in advertising or publicity pertaining to
|
||||||
|
distribution of the software without specific, written prior
|
||||||
|
permission.
|
||||||
|
|
||||||
|
STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO
|
||||||
|
THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||||
|
FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE
|
||||||
|
FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||||
|
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||||
|
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
|
||||||
|
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
ZERO-CLAUSE BSD LICENSE FOR CODE IN THE PYTHON DOCUMENTATION
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
purpose with or without fee is hereby granted.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
PERFORMANCE OF THIS SOFTWARE.
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
Metadata-Version: 2.3
|
||||||
|
Name: aiohappyeyeballs
|
||||||
|
Version: 2.6.1
|
||||||
|
Summary: Happy Eyeballs for asyncio
|
||||||
|
License: PSF-2.0
|
||||||
|
Author: J. Nick Koston
|
||||||
|
Author-email: nick@koston.org
|
||||||
|
Requires-Python: >=3.9
|
||||||
|
Classifier: Development Status :: 5 - Production/Stable
|
||||||
|
Classifier: Intended Audience :: Developers
|
||||||
|
Classifier: Natural Language :: English
|
||||||
|
Classifier: Operating System :: OS Independent
|
||||||
|
Classifier: Topic :: Software Development :: Libraries
|
||||||
|
Classifier: Programming Language :: Python :: 3
|
||||||
|
Classifier: Programming Language :: Python :: 3.9
|
||||||
|
Classifier: Programming Language :: Python :: 3.10
|
||||||
|
Classifier: Programming Language :: Python :: 3.11
|
||||||
|
Classifier: Programming Language :: Python :: 3.12
|
||||||
|
Classifier: Programming Language :: Python :: 3.13
|
||||||
|
Classifier: License :: OSI Approved :: Python Software Foundation License
|
||||||
|
Project-URL: Bug Tracker, https://github.com/aio-libs/aiohappyeyeballs/issues
|
||||||
|
Project-URL: Changelog, https://github.com/aio-libs/aiohappyeyeballs/blob/main/CHANGELOG.md
|
||||||
|
Project-URL: Documentation, https://aiohappyeyeballs.readthedocs.io
|
||||||
|
Project-URL: Repository, https://github.com/aio-libs/aiohappyeyeballs
|
||||||
|
Description-Content-Type: text/markdown
|
||||||
|
|
||||||
|
# aiohappyeyeballs
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/aio-libs/aiohappyeyeballs/actions/workflows/ci.yml?query=branch%3Amain">
|
||||||
|
<img src="https://img.shields.io/github/actions/workflow/status/aio-libs/aiohappyeyeballs/ci-cd.yml?branch=main&label=CI&logo=github&style=flat-square" alt="CI Status" >
|
||||||
|
</a>
|
||||||
|
<a href="https://aiohappyeyeballs.readthedocs.io">
|
||||||
|
<img src="https://img.shields.io/readthedocs/aiohappyeyeballs.svg?logo=read-the-docs&logoColor=fff&style=flat-square" alt="Documentation Status">
|
||||||
|
</a>
|
||||||
|
<a href="https://codecov.io/gh/aio-libs/aiohappyeyeballs">
|
||||||
|
<img src="https://img.shields.io/codecov/c/github/aio-libs/aiohappyeyeballs.svg?logo=codecov&logoColor=fff&style=flat-square" alt="Test coverage percentage">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://python-poetry.org/">
|
||||||
|
<img src="https://img.shields.io/badge/packaging-poetry-299bd7?style=flat-square&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAASCAYAAABrXO8xAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAJJSURBVHgBfZLPa1NBEMe/s7tNXoxW1KJQKaUHkXhQvHgW6UHQQ09CBS/6V3hKc/AP8CqCrUcpmop3Cx48eDB4yEECjVQrlZb80CRN8t6OM/teagVxYZi38+Yz853dJbzoMV3MM8cJUcLMSUKIE8AzQ2PieZzFxEJOHMOgMQQ+dUgSAckNXhapU/NMhDSWLs1B24A8sO1xrN4NECkcAC9ASkiIJc6k5TRiUDPhnyMMdhKc+Zx19l6SgyeW76BEONY9exVQMzKExGKwwPsCzza7KGSSWRWEQhyEaDXp6ZHEr416ygbiKYOd7TEWvvcQIeusHYMJGhTwF9y7sGnSwaWyFAiyoxzqW0PM/RjghPxF2pWReAowTEXnDh0xgcLs8l2YQmOrj3N7ByiqEoH0cARs4u78WgAVkoEDIDoOi3AkcLOHU60RIg5wC4ZuTC7FaHKQm8Hq1fQuSOBvX/sodmNJSB5geaF5CPIkUeecdMxieoRO5jz9bheL6/tXjrwCyX/UYBUcjCaWHljx1xiX6z9xEjkYAzbGVnB8pvLmyXm9ep+W8CmsSHQQY77Zx1zboxAV0w7ybMhQmfqdmmw3nEp1I0Z+FGO6M8LZdoyZnuzzBdjISicKRnpxzI9fPb+0oYXsNdyi+d3h9bm9MWYHFtPeIZfLwzmFDKy1ai3p+PDls1Llz4yyFpferxjnyjJDSEy9CaCx5m2cJPerq6Xm34eTrZt3PqxYO1XOwDYZrFlH1fWnpU38Y9HRze3lj0vOujZcXKuuXm3jP+s3KbZVra7y2EAAAAAASUVORK5CYII=" alt="Poetry">
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/astral-sh/ruff">
|
||||||
|
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json" alt="Ruff">
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/pre-commit/pre-commit">
|
||||||
|
<img src="https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white&style=flat-square" alt="pre-commit">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://pypi.org/project/aiohappyeyeballs/">
|
||||||
|
<img src="https://img.shields.io/pypi/v/aiohappyeyeballs.svg?logo=python&logoColor=fff&style=flat-square" alt="PyPI Version">
|
||||||
|
</a>
|
||||||
|
<img src="https://img.shields.io/pypi/pyversions/aiohappyeyeballs.svg?style=flat-square&logo=python&logoColor=fff" alt="Supported Python versions">
|
||||||
|
<img src="https://img.shields.io/pypi/l/aiohappyeyeballs.svg?style=flat-square" alt="License">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Documentation**: <a href="https://aiohappyeyeballs.readthedocs.io" target="_blank">https://aiohappyeyeballs.readthedocs.io </a>
|
||||||
|
|
||||||
|
**Source Code**: <a href="https://github.com/aio-libs/aiohappyeyeballs" target="_blank">https://github.com/aio-libs/aiohappyeyeballs </a>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[Happy Eyeballs](https://en.wikipedia.org/wiki/Happy_Eyeballs)
|
||||||
|
([RFC 8305](https://www.rfc-editor.org/rfc/rfc8305.html))
|
||||||
|
|
||||||
|
## Use case
|
||||||
|
|
||||||
|
This library exists to allow connecting with
|
||||||
|
[Happy Eyeballs](https://en.wikipedia.org/wiki/Happy_Eyeballs)
|
||||||
|
([RFC 8305](https://www.rfc-editor.org/rfc/rfc8305.html))
|
||||||
|
when you
|
||||||
|
already have a list of addrinfo and not a DNS name.
|
||||||
|
|
||||||
|
The stdlib version of `loop.create_connection()`
|
||||||
|
will only work when you pass in an unresolved name which
|
||||||
|
is not a good fit when using DNS caching or resolving
|
||||||
|
names via another method such as `zeroconf`.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Install this via pip (or your favourite package manager):
|
||||||
|
|
||||||
|
`pip install aiohappyeyeballs`
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[aiohappyeyeballs is licensed under the same terms as cpython itself.](https://github.com/python/cpython/blob/main/LICENSE)
|
||||||
|
|
||||||
|
## Example usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
|
||||||
|
addr_infos = await loop.getaddrinfo("example.org", 80)
|
||||||
|
|
||||||
|
socket = await start_connection(addr_infos)
|
||||||
|
socket = await start_connection(addr_infos, local_addr_infos=local_addr_infos, happy_eyeballs_delay=0.2)
|
||||||
|
|
||||||
|
transport, protocol = await loop.create_connection(
|
||||||
|
MyProtocol, sock=socket, ...)
|
||||||
|
|
||||||
|
# Remove the first address for each family from addr_info
|
||||||
|
pop_addr_infos_interleave(addr_info, 1)
|
||||||
|
|
||||||
|
# Remove all matching address from addr_info
|
||||||
|
remove_addr_infos(addr_info, "dead::beef::")
|
||||||
|
|
||||||
|
# Convert a local_addr to local_addr_infos
|
||||||
|
local_addr_infos = addr_to_addr_infos(("127.0.0.1",0))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
This package contains code from cpython and is licensed under the same terms as cpython itself.
|
||||||
|
|
||||||
|
This package was created with
|
||||||
|
[Copier](https://copier.readthedocs.io/) and the
|
||||||
|
[browniebroke/pypackage-template](https://github.com/browniebroke/pypackage-template)
|
||||||
|
project template.
|
||||||
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
aiohappyeyeballs-2.6.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||||
|
aiohappyeyeballs-2.6.1.dist-info/LICENSE,sha256=Oy-B_iHRgcSZxZolbI4ZaEVdZonSaaqFNzv7avQdo78,13936
|
||||||
|
aiohappyeyeballs-2.6.1.dist-info/METADATA,sha256=NSXlhJwAfi380eEjAo7BQ4P_TVal9xi0qkyZWibMsVM,5915
|
||||||
|
aiohappyeyeballs-2.6.1.dist-info/RECORD,,
|
||||||
|
aiohappyeyeballs-2.6.1.dist-info/WHEEL,sha256=XbeZDeTWKc1w7CSIyre5aMDU_-PohRwTQceYnisIYYY,88
|
||||||
|
aiohappyeyeballs/__init__.py,sha256=x7kktHEtaD9quBcWDJPuLeKyjuVAI-Jj14S9B_5hcTs,361
|
||||||
|
aiohappyeyeballs/__pycache__/__init__.cpython-312.pyc,,
|
||||||
|
aiohappyeyeballs/__pycache__/_staggered.cpython-312.pyc,,
|
||||||
|
aiohappyeyeballs/__pycache__/impl.cpython-312.pyc,,
|
||||||
|
aiohappyeyeballs/__pycache__/types.cpython-312.pyc,,
|
||||||
|
aiohappyeyeballs/__pycache__/utils.cpython-312.pyc,,
|
||||||
|
aiohappyeyeballs/_staggered.py,sha256=edfVowFx-P-ywJjIEF3MdPtEMVODujV6CeMYr65otac,6900
|
||||||
|
aiohappyeyeballs/impl.py,sha256=Dlcm2mTJ28ucrGnxkb_fo9CZzLAkOOBizOt7dreBbXE,9681
|
||||||
|
aiohappyeyeballs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||||
|
aiohappyeyeballs/types.py,sha256=YZJIAnyoV4Dz0WFtlaf_OyE4EW7Xus1z7aIfNI6tDDQ,425
|
||||||
|
aiohappyeyeballs/utils.py,sha256=on9GxIR0LhEfZu8P6Twi9hepX9zDanuZM20MWsb3xlQ,3028
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Wheel-Version: 1.0
|
||||||
|
Generator: poetry-core 2.1.1
|
||||||
|
Root-Is-Purelib: true
|
||||||
|
Tag: py3-none-any
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
__version__ = "2.6.1"
|
||||||
|
|
||||||
|
from .impl import start_connection
|
||||||
|
from .types import AddrInfoType, SocketFactoryType
|
||||||
|
from .utils import addr_to_addr_infos, pop_addr_infos_interleave, remove_addr_infos
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
"AddrInfoType",
|
||||||
|
"SocketFactoryType",
|
||||||
|
"addr_to_addr_infos",
|
||||||
|
"pop_addr_infos_interleave",
|
||||||
|
"remove_addr_infos",
|
||||||
|
"start_connection",
|
||||||
|
)
|
||||||
207
venv/lib/python3.12/site-packages/aiohappyeyeballs/_staggered.py
Normal file
207
venv/lib/python3.12/site-packages/aiohappyeyeballs/_staggered.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
# PY3.9: Import Callable from typing until we drop Python 3.9 support
|
||||||
|
# https://github.com/python/cpython/issues/87131
|
||||||
|
from typing import (
|
||||||
|
TYPE_CHECKING,
|
||||||
|
Any,
|
||||||
|
Awaitable,
|
||||||
|
Callable,
|
||||||
|
Iterable,
|
||||||
|
List,
|
||||||
|
Optional,
|
||||||
|
Set,
|
||||||
|
Tuple,
|
||||||
|
TypeVar,
|
||||||
|
Union,
|
||||||
|
)
|
||||||
|
|
||||||
|
_T = TypeVar("_T")
|
||||||
|
|
||||||
|
RE_RAISE_EXCEPTIONS = (SystemExit, KeyboardInterrupt)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_result(wait_next: "asyncio.Future[None]") -> None:
|
||||||
|
"""Set the result of a future if it is not already done."""
|
||||||
|
if not wait_next.done():
|
||||||
|
wait_next.set_result(None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_one(
|
||||||
|
futures: "Iterable[asyncio.Future[Any]]",
|
||||||
|
loop: asyncio.AbstractEventLoop,
|
||||||
|
) -> _T:
|
||||||
|
"""Wait for the first future to complete."""
|
||||||
|
wait_next = loop.create_future()
|
||||||
|
|
||||||
|
def _on_completion(fut: "asyncio.Future[Any]") -> None:
|
||||||
|
if not wait_next.done():
|
||||||
|
wait_next.set_result(fut)
|
||||||
|
|
||||||
|
for f in futures:
|
||||||
|
f.add_done_callback(_on_completion)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await wait_next
|
||||||
|
finally:
|
||||||
|
for f in futures:
|
||||||
|
f.remove_done_callback(_on_completion)
|
||||||
|
|
||||||
|
|
||||||
|
async def staggered_race(
|
||||||
|
coro_fns: Iterable[Callable[[], Awaitable[_T]]],
|
||||||
|
delay: Optional[float],
|
||||||
|
*,
|
||||||
|
loop: Optional[asyncio.AbstractEventLoop] = None,
|
||||||
|
) -> Tuple[Optional[_T], Optional[int], List[Optional[BaseException]]]:
|
||||||
|
"""
|
||||||
|
Run coroutines with staggered start times and take the first to finish.
|
||||||
|
|
||||||
|
This method takes an iterable of coroutine functions. The first one is
|
||||||
|
started immediately. From then on, whenever the immediately preceding one
|
||||||
|
fails (raises an exception), or when *delay* seconds has passed, the next
|
||||||
|
coroutine is started. This continues until one of the coroutines complete
|
||||||
|
successfully, in which case all others are cancelled, or until all
|
||||||
|
coroutines fail.
|
||||||
|
|
||||||
|
The coroutines provided should be well-behaved in the following way:
|
||||||
|
|
||||||
|
* They should only ``return`` if completed successfully.
|
||||||
|
|
||||||
|
* They should always raise an exception if they did not complete
|
||||||
|
successfully. In particular, if they handle cancellation, they should
|
||||||
|
probably reraise, like this::
|
||||||
|
|
||||||
|
try:
|
||||||
|
# do work
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# undo partially completed work
|
||||||
|
raise
|
||||||
|
|
||||||
|
Args:
|
||||||
|
----
|
||||||
|
coro_fns: an iterable of coroutine functions, i.e. callables that
|
||||||
|
return a coroutine object when called. Use ``functools.partial`` or
|
||||||
|
lambdas to pass arguments.
|
||||||
|
|
||||||
|
delay: amount of time, in seconds, between starting coroutines. If
|
||||||
|
``None``, the coroutines will run sequentially.
|
||||||
|
|
||||||
|
loop: the event loop to use. If ``None``, the running loop is used.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
-------
|
||||||
|
tuple *(winner_result, winner_index, exceptions)* where
|
||||||
|
|
||||||
|
- *winner_result*: the result of the winning coroutine, or ``None``
|
||||||
|
if no coroutines won.
|
||||||
|
|
||||||
|
- *winner_index*: the index of the winning coroutine in
|
||||||
|
``coro_fns``, or ``None`` if no coroutines won. If the winning
|
||||||
|
coroutine may return None on success, *winner_index* can be used
|
||||||
|
to definitively determine whether any coroutine won.
|
||||||
|
|
||||||
|
- *exceptions*: list of exceptions returned by the coroutines.
|
||||||
|
``len(exceptions)`` is equal to the number of coroutines actually
|
||||||
|
started, and the order is the same as in ``coro_fns``. The winning
|
||||||
|
coroutine's entry is ``None``.
|
||||||
|
|
||||||
|
"""
|
||||||
|
loop = loop or asyncio.get_running_loop()
|
||||||
|
exceptions: List[Optional[BaseException]] = []
|
||||||
|
tasks: Set[asyncio.Task[Optional[Tuple[_T, int]]]] = set()
|
||||||
|
|
||||||
|
async def run_one_coro(
|
||||||
|
coro_fn: Callable[[], Awaitable[_T]],
|
||||||
|
this_index: int,
|
||||||
|
start_next: "asyncio.Future[None]",
|
||||||
|
) -> Optional[Tuple[_T, int]]:
|
||||||
|
"""
|
||||||
|
Run a single coroutine.
|
||||||
|
|
||||||
|
If the coroutine fails, set the exception in the exceptions list and
|
||||||
|
start the next coroutine by setting the result of the start_next.
|
||||||
|
|
||||||
|
If the coroutine succeeds, return the result and the index of the
|
||||||
|
coroutine in the coro_fns list.
|
||||||
|
|
||||||
|
If SystemExit or KeyboardInterrupt is raised, re-raise it.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await coro_fn()
|
||||||
|
except RE_RAISE_EXCEPTIONS:
|
||||||
|
raise
|
||||||
|
except BaseException as e:
|
||||||
|
exceptions[this_index] = e
|
||||||
|
_set_result(start_next) # Kickstart the next coroutine
|
||||||
|
return None
|
||||||
|
|
||||||
|
return result, this_index
|
||||||
|
|
||||||
|
start_next_timer: Optional[asyncio.TimerHandle] = None
|
||||||
|
start_next: Optional[asyncio.Future[None]]
|
||||||
|
task: asyncio.Task[Optional[Tuple[_T, int]]]
|
||||||
|
done: Union[asyncio.Future[None], asyncio.Task[Optional[Tuple[_T, int]]]]
|
||||||
|
coro_iter = iter(coro_fns)
|
||||||
|
this_index = -1
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
if coro_fn := next(coro_iter, None):
|
||||||
|
this_index += 1
|
||||||
|
exceptions.append(None)
|
||||||
|
start_next = loop.create_future()
|
||||||
|
task = loop.create_task(run_one_coro(coro_fn, this_index, start_next))
|
||||||
|
tasks.add(task)
|
||||||
|
start_next_timer = (
|
||||||
|
loop.call_later(delay, _set_result, start_next) if delay else None
|
||||||
|
)
|
||||||
|
elif not tasks:
|
||||||
|
# We exhausted the coro_fns list and no tasks are running
|
||||||
|
# so we have no winner and all coroutines failed.
|
||||||
|
break
|
||||||
|
|
||||||
|
while tasks or start_next:
|
||||||
|
done = await _wait_one(
|
||||||
|
(*tasks, start_next) if start_next else tasks, loop
|
||||||
|
)
|
||||||
|
if done is start_next:
|
||||||
|
# The current task has failed or the timer has expired
|
||||||
|
# so we need to start the next task.
|
||||||
|
start_next = None
|
||||||
|
if start_next_timer:
|
||||||
|
start_next_timer.cancel()
|
||||||
|
start_next_timer = None
|
||||||
|
|
||||||
|
# Break out of the task waiting loop to start the next
|
||||||
|
# task.
|
||||||
|
break
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
assert isinstance(done, asyncio.Task)
|
||||||
|
|
||||||
|
tasks.remove(done)
|
||||||
|
if winner := done.result():
|
||||||
|
return *winner, exceptions
|
||||||
|
finally:
|
||||||
|
# We either have:
|
||||||
|
# - a winner
|
||||||
|
# - all tasks failed
|
||||||
|
# - a KeyboardInterrupt or SystemExit.
|
||||||
|
|
||||||
|
#
|
||||||
|
# If the timer is still running, cancel it.
|
||||||
|
#
|
||||||
|
if start_next_timer:
|
||||||
|
start_next_timer.cancel()
|
||||||
|
|
||||||
|
#
|
||||||
|
# If there are any tasks left, cancel them and than
|
||||||
|
# wait them so they fill the exceptions list.
|
||||||
|
#
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
|
||||||
|
return None, None, exceptions
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user