a working product with ugly ui

This commit is contained in:
2025-12-12 20:15:27 +05:00
parent e6d04f986f
commit 4d3085623a
77 changed files with 8750 additions and 0 deletions

View File

@@ -7,6 +7,11 @@ server {
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ws/ {

18
.env.example Normal file
View File

@@ -0,0 +1,18 @@
# Database Configuration
DATABASE_URL=postgresql://mapmaker:mapmaker@database:5432/mapmaker
# Security
SECRET_KEY=generate-secure-random-key-change-in-production
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=7
# Registration
# Set to "true" to allow new user registration, "false" to disable
ALLOW_REGISTRATION=false
# CORS (comma-separated origins)
ALLOWED_ORIGINS=http://localhost:8000,http://localhost:3000
# Environment
ENVIRONMENT=development

78
.gitignore vendored Normal file
View File

@@ -0,0 +1,78 @@
# Environment variables
.env
.env.local
.env.*.local
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual Environment
venv/
ENV/
env/
.venv
# PyCharm
.idea/
# VS Code
.vscode/
# Pytest
.pytest_cache/
.coverage
htmlcov/
# Alembic
# Don't ignore migrations, but ignore these:
*.pyc
# Node.js / Frontend
node_modules/
public/node_modules/
public/dist/
public/build/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Frontend build
public/.vite/
# OS
.DS_Store
Thumbs.db
# Database
*.db
*.sqlite
*.sqlite3
# Logs
logs/
*.log
# Claude Code
.claude/plans/

87
alembic.ini Normal file
View File

@@ -0,0 +1,87 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

79
alembic/env.py Normal file
View File

@@ -0,0 +1,79 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
import sys
from pathlib import Path
# Add the parent directory to the path to import app modules
sys.path.append(str(Path(__file__).resolve().parents[1]))
from app.database import Base
from app.config import settings
from app.models import * # Import all models
# this is the Alembic Config object
config = context.config
# Interpret the config file for Python logging.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Set the SQLAlchemy URL from settings
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
# add your model's MetaData object here for 'autogenerate' support
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True # Enable type comparison for migrations
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

25
alembic/script.py.mako Normal file
View File

@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
import geoalchemy2
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,122 @@
"""Initial schema with PostGIS support
Revision ID: 915e5889d6d7
Revises:
Create Date: 2025-12-12 01:25:16.706772
"""
from alembic import op
import sqlalchemy as sa
import geoalchemy2
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '915e5889d6d7'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# Enable PostGIS extension
op.execute('CREATE EXTENSION IF NOT EXISTS postgis')
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('username', sa.String(length=50), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('is_admin', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
op.create_table('maps',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('owner_id', sa.UUID(), nullable=False),
sa.Column('is_default_public', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_maps_owner_id'), 'maps', ['owner_id'], unique=False)
op.create_table('map_items',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('map_id', sa.UUID(), nullable=False),
sa.Column('type', sa.String(length=50), nullable=False),
sa.Column('geometry', geoalchemy2.types.Geography(srid=4326, from_text='ST_GeogFromText', name='geography', nullable=False), nullable=False),
sa.Column('properties', postgresql.JSONB(astext_type=sa.Text()), server_default='{}', nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('created_by', sa.UUID(), nullable=True),
sa.Column('updated_by', sa.UUID(), nullable=True),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
sa.ForeignKeyConstraint(['map_id'], ['maps.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['updated_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
# Note: GeoAlchemy2 automatically creates a GIST index on Geography columns
# So we don't need to create it explicitly here
# op.create_index('idx_map_items_geometry', 'map_items', ['geometry'], unique=False, postgresql_using='gist')
op.create_index(op.f('ix_map_items_map_id'), 'map_items', ['map_id'], unique=False)
op.create_index(op.f('ix_map_items_type'), 'map_items', ['type'], unique=False)
op.create_table('sessions',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=True),
sa.Column('map_id', sa.UUID(), nullable=False),
sa.Column('socket_id', sa.String(length=255), nullable=False),
sa.Column('username', sa.String(length=50), nullable=True),
sa.Column('connected_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('last_seen', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['map_id'], ['maps.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_sessions_map_id'), 'sessions', ['map_id'], unique=False)
op.create_index(op.f('ix_sessions_user_id'), 'sessions', ['user_id'], unique=False)
op.create_table('shares',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('map_id', sa.UUID(), nullable=False),
sa.Column('share_token', sa.String(length=64), nullable=False),
sa.Column('access_level', sa.String(length=20), nullable=False),
sa.Column('requires_auth', sa.Boolean(), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('created_by', sa.UUID(), nullable=True),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
sa.ForeignKeyConstraint(['map_id'], ['maps.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_shares_map_id'), 'shares', ['map_id'], unique=False)
op.create_index(op.f('ix_shares_share_token'), 'shares', ['share_token'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# Drop our application tables
op.drop_index(op.f('ix_shares_share_token'), table_name='shares')
op.drop_index(op.f('ix_shares_map_id'), table_name='shares')
op.drop_table('shares')
op.drop_index(op.f('ix_sessions_user_id'), table_name='sessions')
op.drop_index(op.f('ix_sessions_map_id'), table_name='sessions')
op.drop_table('sessions')
op.drop_index(op.f('ix_map_items_type'), table_name='map_items')
op.drop_index(op.f('ix_map_items_map_id'), table_name='map_items')
op.drop_index('idx_map_items_geometry', table_name='map_items', postgresql_using='gist')
op.drop_table('map_items')
op.drop_index(op.f('ix_maps_owner_id'), table_name='maps')
op.drop_table('maps')
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
# Optionally drop PostGIS extension (commented out to preserve it)
# op.execute('DROP EXTENSION IF EXISTS postgis CASCADE')
# ### end Alembic commands ###

0
app/__init__.py Normal file
View File

39
app/config.py Normal file
View File

@@ -0,0 +1,39 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import List
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# Database
DATABASE_URL: str
# Security
SECRET_KEY: str
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# Registration
ALLOW_REGISTRATION: bool = False
# CORS
ALLOWED_ORIGINS: str = "http://localhost:8000"
# Environment
ENVIRONMENT: str = "development"
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=True
)
@property
def cors_origins(self) -> List[str]:
"""Parse comma-separated CORS origins into a list."""
return [origin.strip() for origin in self.ALLOWED_ORIGINS.split(",")]
# Global settings instance
settings = Settings()

29
app/database.py Normal file
View File

@@ -0,0 +1,29 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.config import settings
# Create SQLAlchemy engine
engine = create_engine(
settings.DATABASE_URL,
pool_pre_ping=True,
echo=settings.ENVIRONMENT == "development"
)
# Create SessionLocal class
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Base class for models
Base = declarative_base()
def get_db():
"""
Dependency function to get database session.
Yields a database session and ensures it's closed after use.
"""
db = SessionLocal()
try:
yield db
finally:
db.close()

94
app/dependencies.py Normal file
View File

@@ -0,0 +1,94 @@
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from jose import JWTError, jwt
from app.database import get_db
from app.config import settings
from app.models.user import User
# Security scheme for JWT
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> User:
"""
Dependency to get the current authenticated user from JWT token.
Raises HTTP 401 if token is invalid or user not found.
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
# Decode JWT token
token = credentials.credentials
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
# Get user from database
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise credentials_exception
return user
async def get_current_active_user(
current_user: User = Depends(get_current_user)
) -> User:
"""
Dependency to get the current active user.
Can be extended to check if user is active/banned.
"""
# Add user.is_active check here if needed in the future
return current_user
async def get_current_admin_user(
current_user: User = Depends(get_current_user)
) -> User:
"""
Dependency to get the current admin user.
Raises HTTP 403 if user is not an admin.
"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return current_user
async def get_optional_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer(auto_error=False)),
db: Session = Depends(get_db)
) -> Optional[User]:
"""
Dependency to optionally get the current user.
Returns None if no token provided or token is invalid.
Useful for endpoints that work for both authenticated and guest users.
"""
if credentials is None:
return None
try:
token = credentials.credentials
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
user_id: str = payload.get("sub")
if user_id is None:
return None
user = db.query(User).filter(User.id == user_id).first()
return user
except JWTError:
return None

44
app/main.py Normal file
View File

@@ -0,0 +1,44 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import auth, maps, items
# Create FastAPI application
app = FastAPI(
title="ISP Wiremap API",
description="API for ISP cable and network infrastructure mapping",
version="1.0.0"
)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Register routers
app.include_router(auth.router)
app.include_router(maps.router)
app.include_router(items.router)
@app.get("/")
async def root():
"""Root endpoint for API health check."""
return {
"message": "ISP Wiremap API",
"version": "1.0.0",
"status": "running"
}
@app.get("/api/health")
async def health_check():
"""Health check endpoint for monitoring."""
return {
"status": "healthy",
"environment": settings.ENVIRONMENT
}

7
app/models/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
from app.models.user import User
from app.models.map import Map
from app.models.map_item import MapItem
from app.models.share import Share
from app.models.session import Session
__all__ = ["User", "Map", "MapItem", "Share", "Session"]

19
app/models/map.py Normal file
View File

@@ -0,0 +1,19 @@
from sqlalchemy import Column, String, Boolean, DateTime, Text, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func
import uuid
from app.database import Base
class Map(Base):
"""Map model for storing map metadata and ownership."""
__tablename__ = "maps"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
owner_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
is_default_public = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)

46
app/models/map_item.py Normal file
View File

@@ -0,0 +1,46 @@
from sqlalchemy import Column, String, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.sql import func
from geoalchemy2 import Geography
import uuid
from app.database import Base
class MapItem(Base):
"""
Map item model for storing cables, switches, APs, and wireless mesh.
Types:
- cable: Fiber, Cat6, Cat6 PoE
- wireless_mesh: Wireless connections between APs
- switch: Network switch
- indoor_ap: Indoor access point
- outdoor_ap: Outdoor access point
Geometry:
- Point for devices (switches, APs)
- LineString for cables and wireless mesh
Properties (JSONB):
- For cables: cable_type, name, notes, length_meters, start_device_id, end_device_id
- For devices: name, notes, port_count, connections (array of {cable_id, port_number})
- For wireless_mesh: name, notes, start_ap_id, end_ap_id
"""
__tablename__ = "map_items"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
map_id = Column(UUID(as_uuid=True), ForeignKey("maps.id", ondelete="CASCADE"), nullable=False, index=True)
type = Column(String(50), nullable=False, index=True)
# PostGIS geography column for spatial data
# Using GeometryCollection to support both Point and LineString geometries
geometry = Column(Geography(geometry_type='GEOMETRY', srid=4326), nullable=False)
# JSONB column for flexible properties based on item type
properties = Column(JSONB, nullable=False, server_default='{}')
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
updated_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)

22
app/models/session.py Normal file
View File

@@ -0,0 +1,22 @@
from sqlalchemy import Column, String, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func
import uuid
from app.database import Base
class Session(Base):
"""Session model for tracking WebSocket connections and user presence."""
__tablename__ = "sessions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True)
map_id = Column(UUID(as_uuid=True), ForeignKey("maps.id", ondelete="CASCADE"), nullable=False, index=True)
socket_id = Column(String(255), nullable=False)
# Denormalized username for quick access
username = Column(String(50), nullable=True)
connected_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
last_seen = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)

27
app/models/share.py Normal file
View File

@@ -0,0 +1,27 @@
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func
import uuid
from app.database import Base
class Share(Base):
"""Share model for managing map access via share links."""
__tablename__ = "shares"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
map_id = Column(UUID(as_uuid=True), ForeignKey("maps.id", ondelete="CASCADE"), nullable=False, index=True)
share_token = Column(String(64), unique=True, nullable=False, index=True)
# Access levels: 'read_only', 'edit', 'guest_read_only'
access_level = Column(String(20), nullable=False)
# Whether authentication is required to use this share link
requires_auth = Column(Boolean, default=True, nullable=False)
# Optional expiration date
expires_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)

19
app/models/user.py Normal file
View File

@@ -0,0 +1,19 @@
from sqlalchemy import Column, String, Boolean, DateTime
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func
import uuid
from app.database import Base
class User(Base):
"""User model for authentication and ownership."""
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username = Column(String(50), unique=True, nullable=False, index=True)
email = Column(String(255), unique=True, nullable=False, index=True)
password_hash = Column(String(255), nullable=False)
is_admin = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)

0
app/routers/__init__.py Normal file
View File

80
app/routers/auth.py Normal file
View File

@@ -0,0 +1,80 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies import get_current_user
from app.schemas.auth import LoginRequest, TokenResponse
from app.schemas.user import UserCreate, UserResponse, UserWithToken
from app.services.auth_service import authenticate_user, create_user, create_tokens_for_user
from app.models.user import User
router = APIRouter(prefix="/api/auth", tags=["authentication"])
@router.post("/register", response_model=UserWithToken, status_code=status.HTTP_201_CREATED)
async def register(
user_data: UserCreate,
db: Session = Depends(get_db)
):
"""
Register a new user.
Registration must be enabled via ALLOW_REGISTRATION environment variable.
"""
user = create_user(db, user_data)
tokens = create_tokens_for_user(user)
return UserWithToken(
id=user.id,
username=user.username,
email=user.email,
is_admin=user.is_admin,
created_at=user.created_at,
updated_at=user.updated_at,
access_token=tokens.access_token,
refresh_token=tokens.refresh_token,
token_type=tokens.token_type
)
@router.post("/login", response_model=TokenResponse)
async def login(
credentials: LoginRequest,
db: Session = Depends(get_db)
):
"""
Login with username and password.
Returns JWT access and refresh tokens.
"""
user = authenticate_user(db, credentials.username, credentials.password)
tokens = create_tokens_for_user(user)
return tokens
@router.get("/me", response_model=UserResponse)
async def get_current_user_info(
current_user: User = Depends(get_current_user)
):
"""
Get current authenticated user information.
Requires valid JWT token in Authorization header.
"""
return UserResponse(
id=current_user.id,
username=current_user.username,
email=current_user.email,
is_admin=current_user.is_admin,
created_at=current_user.created_at,
updated_at=current_user.updated_at
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_access_token(
current_user: User = Depends(get_current_user)
):
"""
Refresh access token using a valid refresh token.
Returns new access and refresh tokens.
"""
tokens = create_tokens_for_user(current_user)
return tokens

90
app/routers/items.py Normal file
View File

@@ -0,0 +1,90 @@
"""Map items router for CRUD operations."""
from typing import List
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies import get_current_user, get_optional_current_user
from app.models.user import User
from app.schemas.map_item import MapItemCreate, MapItemUpdate, MapItemResponse
from app.services import item_service
from app.services.item_service import geography_to_geojson
router = APIRouter(prefix="/api/maps/{map_id}/items", tags=["map-items"])
def format_item_response(item) -> dict:
"""Format map item for response with GeoJSON geometry."""
return {
"id": str(item.id),
"map_id": str(item.map_id),
"type": item.type,
"geometry": geography_to_geojson(item.geometry),
"properties": item.properties,
"created_at": item.created_at.isoformat(),
"updated_at": item.updated_at.isoformat(),
"created_by": str(item.created_by) if item.created_by else None,
"updated_by": str(item.updated_by) if item.updated_by else None,
}
@router.get("", response_model=List[dict])
async def list_map_items(
map_id: UUID,
current_user: User = Depends(get_optional_current_user),
db: Session = Depends(get_db)
):
"""Get all items for a map."""
items = item_service.get_map_items(db, map_id, current_user)
return [format_item_response(item) for item in items]
@router.post("", response_model=dict, status_code=status.HTTP_201_CREATED)
async def create_map_item(
map_id: UUID,
item_data: MapItemCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create a new map item."""
item = item_service.create_map_item(db, map_id, item_data, current_user)
return format_item_response(item)
@router.get("/{item_id}", response_model=dict)
async def get_map_item(
map_id: UUID,
item_id: UUID,
current_user: User = Depends(get_optional_current_user),
db: Session = Depends(get_db)
):
"""Get a specific map item."""
item = item_service.get_map_item_by_id(db, item_id, current_user)
return format_item_response(item)
@router.patch("/{item_id}", response_model=dict)
async def update_map_item(
map_id: UUID,
item_id: UUID,
item_data: MapItemUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Update a map item."""
item = item_service.update_map_item(db, item_id, item_data, current_user)
return format_item_response(item)
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_map_item(
map_id: UUID,
item_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Delete a map item."""
item_service.delete_map_item(db, item_id, current_user)
return None

94
app/routers/maps.py Normal file
View File

@@ -0,0 +1,94 @@
"""Maps router for CRUD operations."""
from typing import List
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies import get_current_user, get_optional_current_user
from app.models.user import User
from app.schemas.map import MapCreate, MapUpdate, MapResponse
from app.services import map_service
router = APIRouter(prefix="/api/maps", tags=["maps"])
@router.get("", response_model=List[MapResponse])
async def list_user_maps(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get all maps owned by the current user."""
maps = map_service.get_user_maps(db, current_user.id)
return maps
@router.post("", response_model=MapResponse, status_code=status.HTTP_201_CREATED)
async def create_map(
map_data: MapCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create a new map."""
new_map = map_service.create_map(db, map_data, current_user.id)
return new_map
@router.get("/public", response_model=MapResponse)
async def get_public_map(db: Session = Depends(get_db)):
"""Get the default public map (no authentication required)."""
public_map = map_service.get_default_public_map(db)
if not public_map:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No public map available"
)
return public_map
@router.get("/{map_id}", response_model=MapResponse)
async def get_map(
map_id: UUID,
current_user: User = Depends(get_optional_current_user),
db: Session = Depends(get_db)
):
"""Get a specific map by ID. Requires authentication unless it's the public map."""
map_obj = map_service.get_map_by_id(db, map_id, current_user)
return map_obj
@router.patch("/{map_id}", response_model=MapResponse)
async def update_map(
map_id: UUID,
map_data: MapUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Update a map. Only the owner or admin can update."""
updated_map = map_service.update_map(db, map_id, map_data, current_user)
return updated_map
@router.delete("/{map_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_map(
map_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Delete a map. Only the owner or admin can delete."""
map_service.delete_map(db, map_id, current_user)
return None
@router.post("/{map_id}/set-default-public", response_model=MapResponse)
async def set_default_public(
map_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Set a map as the default public map. Admin only."""
updated_map = map_service.set_default_public_map(db, map_id, current_user)
return updated_map

0
app/schemas/__init__.py Normal file
View File

19
app/schemas/auth.py Normal file
View File

@@ -0,0 +1,19 @@
from pydantic import BaseModel, EmailStr
class LoginRequest(BaseModel):
"""Request schema for user login."""
username: str
password: str
class TokenResponse(BaseModel):
"""Response schema for authentication tokens."""
access_token: str
refresh_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
"""Schema for JWT token payload data."""
user_id: str

32
app/schemas/map.py Normal file
View File

@@ -0,0 +1,32 @@
from pydantic import BaseModel, ConfigDict
from typing import Optional
from datetime import datetime
from uuid import UUID
class MapBase(BaseModel):
"""Base map schema with common attributes."""
name: str
description: Optional[str] = None
class MapCreate(MapBase):
"""Schema for creating a new map."""
pass
class MapUpdate(BaseModel):
"""Schema for updating map information."""
name: Optional[str] = None
description: Optional[str] = None
class MapResponse(MapBase):
"""Response schema for map data."""
id: UUID
owner_id: UUID
is_default_public: bool
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)

35
app/schemas/map_item.py Normal file
View File

@@ -0,0 +1,35 @@
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional, Dict, Any
from datetime import datetime
from uuid import UUID
class MapItemBase(BaseModel):
"""Base map item schema with common attributes."""
type: str = Field(..., description="Item type: cable, wireless_mesh, switch, indoor_ap, outdoor_ap")
geometry: Dict[str, Any] = Field(..., description="GeoJSON geometry (Point or LineString)")
properties: Dict[str, Any] = Field(default_factory=dict, description="Item-specific properties")
class MapItemCreate(MapItemBase):
"""Schema for creating a new map item."""
pass
class MapItemUpdate(BaseModel):
"""Schema for updating map item information."""
type: Optional[str] = None
geometry: Optional[Dict[str, Any]] = None
properties: Optional[Dict[str, Any]] = None
class MapItemResponse(MapItemBase):
"""Response schema for map item data."""
id: UUID
map_id: UUID
created_at: datetime
updated_at: datetime
created_by: Optional[UUID] = None
updated_by: Optional[UUID] = None
model_config = ConfigDict(from_attributes=True)

43
app/schemas/share.py Normal file
View File

@@ -0,0 +1,43 @@
from pydantic import BaseModel, ConfigDict
from typing import Optional
from datetime import datetime
from uuid import UUID
class ShareBase(BaseModel):
"""Base share schema with common attributes."""
access_level: str # 'read_only', 'edit', 'guest_read_only'
requires_auth: bool = True
expires_at: Optional[datetime] = None
class ShareCreate(ShareBase):
"""Schema for creating a new share link."""
pass
class ShareUpdate(BaseModel):
"""Schema for updating share information."""
access_level: Optional[str] = None
requires_auth: Optional[bool] = None
expires_at: Optional[datetime] = None
class ShareResponse(ShareBase):
"""Response schema for share data."""
id: UUID
map_id: UUID
share_token: str
created_at: datetime
created_by: Optional[UUID] = None
model_config = ConfigDict(from_attributes=True)
class ShareValidateResponse(BaseModel):
"""Response schema for share token validation."""
valid: bool
access_level: Optional[str] = None
map_id: Optional[UUID] = None
requires_auth: bool = False
expired: bool = False

39
app/schemas/user.py Normal file
View File

@@ -0,0 +1,39 @@
from pydantic import BaseModel, EmailStr, ConfigDict
from typing import Optional
from datetime import datetime
from uuid import UUID
class UserBase(BaseModel):
"""Base user schema with common attributes."""
username: str
email: EmailStr
class UserCreate(UserBase):
"""Schema for creating a new user."""
password: str
class UserUpdate(BaseModel):
"""Schema for updating user information."""
username: Optional[str] = None
email: Optional[EmailStr] = None
password: Optional[str] = None
class UserResponse(UserBase):
"""Response schema for user data (without sensitive info)."""
id: UUID
is_admin: bool
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class UserWithToken(UserResponse):
"""User response with authentication tokens."""
access_token: str
refresh_token: str
token_type: str = "bearer"

0
app/services/__init__.py Normal file
View File

View File

@@ -0,0 +1,106 @@
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from app.models.user import User
from app.schemas.user import UserCreate
from app.schemas.auth import TokenResponse
from app.utils.password import hash_password, verify_password
from app.utils.security import create_access_token, create_refresh_token
from app.config import settings
def authenticate_user(db: Session, username: str, password: str) -> User:
"""
Authenticate a user by username and password.
Args:
db: Database session
username: Username
password: Plain text password
Returns:
User object if authentication successful
Raises:
HTTPException: If authentication fails
"""
user = db.query(User).filter(User.username == username).first()
if not user or not verify_password(password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
return user
def create_user(db: Session, user_data: UserCreate) -> User:
"""
Create a new user.
Args:
db: Database session
user_data: User creation data
Returns:
Created user object
Raises:
HTTPException: If username or email already exists
"""
# Check if registration is allowed
if not settings.ALLOW_REGISTRATION:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User registration is currently disabled"
)
# Check if username already exists
if db.query(User).filter(User.username == user_data.username).first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered"
)
# Check if email already exists
if db.query(User).filter(User.email == user_data.email).first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Create new user
hashed_password = hash_password(user_data.password)
db_user = User(
username=user_data.username,
email=user_data.email,
password_hash=hashed_password,
is_admin=False
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
def create_tokens_for_user(user: User) -> TokenResponse:
"""
Create access and refresh tokens for a user.
Args:
user: User object
Returns:
TokenResponse with access and refresh tokens
"""
access_token = create_access_token(str(user.id))
refresh_token = create_refresh_token(str(user.id))
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
token_type="bearer"
)

View File

@@ -0,0 +1,227 @@
"""Map item service for business logic."""
from typing import List, Optional
from uuid import UUID
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from geoalchemy2.shape import from_shape, to_shape
from shapely.geometry import shape, Point, LineString
import json
from app.models.map_item import MapItem
from app.models.user import User
from app.schemas.map_item import MapItemCreate, MapItemUpdate
from app.services.map_service import get_map_by_id
def get_map_items(db: Session, map_id: UUID, user: Optional[User] = None) -> List[MapItem]:
"""Get all items for a map."""
# Verify user has access to the map
get_map_by_id(db, map_id, user)
items = db.query(MapItem).filter(MapItem.map_id == map_id).all()
return items
def get_map_item_by_id(db: Session, item_id: UUID, user: Optional[User] = None) -> MapItem:
"""Get a map item by ID."""
item = db.query(MapItem).filter(MapItem.id == item_id).first()
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Map item not found"
)
# Verify user has access to the map
get_map_by_id(db, item.map_id, user)
return item
def geojson_to_geography(geojson: dict) -> str:
"""Convert GeoJSON geometry to PostGIS geography WKT."""
geom = shape(geojson)
# Ensure coordinates are in the correct format for PostGIS (lon, lat)
if isinstance(geom, Point):
return f'SRID=4326;POINT({geom.x} {geom.y})'
elif isinstance(geom, LineString):
coords = ', '.join([f'{x} {y}' for x, y in geom.coords])
return f'SRID=4326;LINESTRING({coords})'
else:
raise ValueError(f"Unsupported geometry type: {type(geom)}")
def geography_to_geojson(geography) -> dict:
"""Convert PostGIS geography to GeoJSON."""
geom = to_shape(geography)
return json.loads(json.dumps(geom.__geo_interface__))
def create_map_item(db: Session, map_id: UUID, item_data: MapItemCreate, user: User) -> MapItem:
"""Create a new map item."""
# Verify user has access to the map
get_map_by_id(db, map_id, user)
# Convert GeoJSON to PostGIS geography
geometry_wkt = geojson_to_geography(item_data.geometry)
item = MapItem(
map_id=map_id,
type=item_data.type,
geometry=geometry_wkt,
properties=item_data.properties,
created_by=user.id,
updated_by=user.id
)
db.add(item)
db.commit()
db.refresh(item)
# If this is a cable with device connections, update device port tracking
if item.type == 'cable':
start_device_id = item.properties.get('start_device_id')
end_device_id = item.properties.get('end_device_id')
print(f"Cable created: start_device_id={start_device_id}, end_device_id={end_device_id}")
if start_device_id:
print(f"Updating port connections for start device: {start_device_id}")
update_device_connections(db, UUID(start_device_id), item.id)
if end_device_id:
print(f"Updating port connections for end device: {end_device_id}")
update_device_connections(db, UUID(end_device_id), item.id)
return item
def update_device_connections(db: Session, device_id: UUID, cable_id: UUID) -> None:
"""Add cable connection to device's connections array."""
print(f"update_device_connections called: device_id={device_id}, cable_id={cable_id}")
device = db.query(MapItem).filter(MapItem.id == device_id).first()
if not device:
print(f"Device not found: {device_id}")
return
# Create a mutable copy of properties
properties = dict(device.properties) if device.properties else {}
connections = properties.get('connections', [])
port_count = properties.get('port_count', 0)
print(f"Device {device_id}: port_count={port_count}, current_connections={len(connections)}")
# Find next available port
used_ports = {conn.get('port_number') for conn in connections if isinstance(conn, dict)}
next_port = 1
while next_port in used_ports and next_port <= port_count:
next_port += 1
# Only add if there's an available port
if next_port <= port_count:
connections.append({
'cable_id': str(cable_id),
'port_number': next_port
})
properties['connections'] = connections
# Mark the column as modified so SQLAlchemy detects the change
from sqlalchemy.orm.attributes import flag_modified
device.properties = properties
flag_modified(device, 'properties')
db.commit()
db.refresh(device)
print(f"Added connection to port {next_port}. Total connections now: {len(connections)}")
print(f"Device properties after update: {device.properties}")
else:
print(f"No available ports! Port count: {port_count}, used: {len(connections)}")
def update_map_item(db: Session, item_id: UUID, item_data: MapItemUpdate, user: User) -> MapItem:
"""Update a map item."""
item = get_map_item_by_id(db, item_id, user)
# Update fields if provided
if item_data.type is not None:
item.type = item_data.type
if item_data.geometry is not None:
item.geometry = geojson_to_geography(item_data.geometry)
if item_data.properties is not None:
item.properties = item_data.properties
item.updated_by = user.id
db.commit()
db.refresh(item)
return item
def delete_map_item(db: Session, item_id: UUID, user: User) -> None:
"""Delete a map item."""
item = get_map_item_by_id(db, item_id, user)
# If deleting a cable, remove it from device connections
if item.type == 'cable':
start_device_id = item.properties.get('start_device_id')
end_device_id = item.properties.get('end_device_id')
if start_device_id:
remove_device_connection(db, UUID(start_device_id), item.id)
if end_device_id:
remove_device_connection(db, UUID(end_device_id), item.id)
# If deleting an AP, delete all associated wireless mesh links
if item.type in ['indoor_ap', 'outdoor_ap']:
print(f"Deleting AP {item.id}, checking for wireless mesh links...")
# Find all wireless mesh links connected to this AP
wireless_meshes = db.query(MapItem).filter(
MapItem.map_id == item.map_id,
MapItem.type == 'wireless_mesh'
).all()
for mesh in wireless_meshes:
start_ap_id = mesh.properties.get('start_ap_id')
end_ap_id = mesh.properties.get('end_ap_id')
if start_ap_id == str(item.id) or end_ap_id == str(item.id):
print(f"Deleting wireless mesh {mesh.id} connected to AP {item.id}")
db.delete(mesh)
db.delete(item)
db.commit()
def remove_device_connection(db: Session, device_id: UUID, cable_id: UUID) -> None:
"""Remove cable connection from device's connections array."""
print(f"remove_device_connection called: device_id={device_id}, cable_id={cable_id}")
device = db.query(MapItem).filter(MapItem.id == device_id).first()
if not device:
print(f"Device not found: {device_id}")
return
# Create a mutable copy of properties
properties = dict(device.properties) if device.properties else {}
connections = properties.get('connections', [])
print(f"Before removal: {len(connections)} connections")
# Filter out the cable connection
connections = [
conn for conn in connections
if isinstance(conn, dict) and conn.get('cable_id') != str(cable_id)
]
print(f"After removal: {len(connections)} connections")
properties['connections'] = connections
# Mark the column as modified so SQLAlchemy detects the change
from sqlalchemy.orm.attributes import flag_modified
device.properties = properties
flag_modified(device, 'properties')
db.commit()
db.refresh(device)
print(f"Removed cable connection. Device now has {len(connections)} connections")

103
app/services/map_service.py Normal file
View File

@@ -0,0 +1,103 @@
"""Map service for business logic."""
from typing import List, Optional
from uuid import UUID
from sqlalchemy.orm import Session
from fastapi import HTTPException, status
from app.models.map import Map
from app.models.user import User
from app.schemas.map import MapCreate, MapUpdate
def get_user_maps(db: Session, user_id: UUID) -> List[Map]:
"""Get all maps owned by a user."""
return db.query(Map).filter(Map.owner_id == user_id).order_by(Map.updated_at.desc()).all()
def get_map_by_id(db: Session, map_id: UUID, user: Optional[User] = None) -> Map:
"""Get a map by ID with optional authorization check."""
map_obj = db.query(Map).filter(Map.id == map_id).first()
if not map_obj:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Map not found"
)
# If user is provided, check authorization
if user:
if map_obj.owner_id != user.id and not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to access this map"
)
return map_obj
def get_default_public_map(db: Session) -> Optional[Map]:
"""Get the default public map (first map with is_default_public=True)."""
return db.query(Map).filter(Map.is_default_public == True).first()
def create_map(db: Session, map_data: MapCreate, user_id: UUID) -> Map:
"""Create a new map."""
map_obj = Map(
name=map_data.name,
description=map_data.description,
owner_id=user_id,
is_default_public=False
)
db.add(map_obj)
db.commit()
db.refresh(map_obj)
return map_obj
def update_map(db: Session, map_id: UUID, map_data: MapUpdate, user: User) -> Map:
"""Update a map. Only owner or admin can update."""
map_obj = get_map_by_id(db, map_id, user)
# Update fields if provided
if map_data.name is not None:
map_obj.name = map_data.name
if map_data.description is not None:
map_obj.description = map_data.description
db.commit()
db.refresh(map_obj)
return map_obj
def delete_map(db: Session, map_id: UUID, user: User) -> None:
"""Delete a map. Only owner or admin can delete."""
map_obj = get_map_by_id(db, map_id, user)
db.delete(map_obj)
db.commit()
def set_default_public_map(db: Session, map_id: UUID, user: User) -> Map:
"""Set a map as the default public map. Only admins can do this."""
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only administrators can set the default public map"
)
# Get the map (admin check is done in get_map_by_id)
map_obj = get_map_by_id(db, map_id, user)
# Unset any existing default public maps
db.query(Map).filter(Map.is_default_public == True).update({"is_default_public": False})
# Set this map as default public
map_obj.is_default_public = True
db.commit()
db.refresh(map_obj)
return map_obj

0
app/utils/__init__.py Normal file
View File

31
app/utils/password.py Normal file
View File

@@ -0,0 +1,31 @@
from passlib.context import CryptContext
# Password hashing context using bcrypt
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
"""
Hash a plain password using bcrypt.
Args:
password: Plain text password
Returns:
Hashed password string
"""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verify a plain password against a hashed password.
Args:
plain_password: Plain text password to verify
hashed_password: Hashed password from database
Returns:
True if password matches, False otherwise
"""
return pwd_context.verify(plain_password, hashed_password)

69
app/utils/security.py Normal file
View File

@@ -0,0 +1,69 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import jwt
from app.config import settings
def create_access_token(user_id: str, expires_delta: Optional[timedelta] = None) -> str:
"""
Create a JWT access token.
Args:
user_id: User ID to encode in the token
expires_delta: Optional custom expiration time delta
Returns:
Encoded JWT token string
"""
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode = {
"sub": user_id,
"exp": expire,
"type": "access"
}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def create_refresh_token(user_id: str) -> str:
"""
Create a JWT refresh token with longer expiration.
Args:
user_id: User ID to encode in the token
Returns:
Encoded JWT refresh token string
"""
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode = {
"sub": user_id,
"exp": expire,
"type": "refresh"
}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def decode_token(token: str) -> dict:
"""
Decode and verify a JWT token.
Args:
token: JWT token string
Returns:
Decoded token payload
Raises:
JWTError: If token is invalid or expired
"""
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload

View File

24
public/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
public/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
public/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
public/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ISP Wiremap</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4346
public/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
public/package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "public",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.90.12",
"@types/leaflet": "^1.9.21",
"axios": "^1.13.2",
"leaflet": "^1.9.4",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.10.1",
"zustand": "^5.0.9"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/postcss": "^4.1.18",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.22",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

6
public/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

1
public/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
public/src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

25
public/src/App.tsx Normal file
View File

@@ -0,0 +1,25 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { Login } from './components/auth/Login';
import { ProtectedRoute } from './components/auth/ProtectedRoute';
import { Dashboard } from './pages/Dashboard';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
);
}
export default App;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,78 @@
import { useState, FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
export function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const { login, isLoading, error, clearError } = useAuthStore();
const navigate = useNavigate();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
clearError();
try {
await login(username, password);
navigate('/');
} catch (err) {
// Error is handled by the store
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
<h1 className="text-2xl font-bold mb-6 text-center text-gray-800">
ISP Wiremap
</h1>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isLoading}
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Logging in...' : 'Login'}
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { useEffect } from 'react';
import { Navigate } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated, loadUser, isLoading } = useAuthStore();
useEffect(() => {
if (isAuthenticated) {
loadUser();
}
}, [isAuthenticated, loadUser]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-gray-600">Loading...</div>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,50 @@
import { ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
interface LayoutProps {
children: ReactNode;
}
export function Layout({ children }: LayoutProps) {
const { user, logout } = useAuthStore();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate('/login');
};
return (
<div className="h-screen flex flex-col">
<header className="bg-blue-600 text-white shadow-md">
<div className="px-4 py-3 flex items-center justify-between">
<h1 className="text-xl font-bold">ISP Wiremap</h1>
{user && (
<div className="flex items-center gap-4">
<span className="text-sm">
{user.username}
{user.is_admin && (
<span className="ml-2 px-2 py-0.5 bg-blue-500 rounded text-xs">
Admin
</span>
)}
</span>
<button
onClick={handleLogout}
className="px-3 py-1 bg-blue-700 hover:bg-blue-800 rounded text-sm"
>
Logout
</button>
</div>
)}
</div>
</header>
<main className="flex-1 overflow-hidden">
{children}
</main>
</div>
);
}

View File

@@ -0,0 +1,338 @@
import { useEffect, useState } from 'react';
import { useMapEvents, Polyline, Marker } from 'react-leaflet';
import L from 'leaflet';
import { useDrawingStore } from '../../stores/drawingStore';
import { mapItemService } from '../../services/mapItemService';
import { CABLE_COLORS, type CableType } from '../../types/mapItem';
interface DrawingHandlerProps {
mapId: string;
onItemCreated: () => void;
}
export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
const { activeTool, isDrawing, drawingPoints, setIsDrawing, addDrawingPoint, resetDrawing, setActiveTool } =
useDrawingStore();
const [cursorPosition, setCursorPosition] = useState<[number, number] | null>(null);
const [allItems, setAllItems] = useState<any[]>([]);
const [startDeviceId, setStartDeviceId] = useState<string | null>(null);
const [endDeviceId, setEndDeviceId] = useState<string | null>(null);
const isCableTool = ['fiber', 'cat6', 'cat6_poe'].includes(activeTool);
const isDeviceTool = ['switch', 'indoor_ap', 'outdoor_ap'].includes(activeTool);
const isWirelessTool = activeTool === 'wireless_mesh';
// Load all map items for snapping
useEffect(() => {
const loadItems = async () => {
try {
const items = await mapItemService.getMapItems(mapId);
setAllItems(items);
} catch (error) {
console.error('Failed to load items for snapping:', error);
}
};
loadItems();
}, [mapId]);
// Find nearby device for snapping
const findNearbyDevice = (lat: number, lng: number, radiusMeters = 5): any | null => {
const devices = allItems.filter(item =>
['switch', 'indoor_ap', 'outdoor_ap'].includes(item.type) &&
item.geometry.type === 'Point'
);
for (const device of devices) {
const [deviceLng, deviceLat] = device.geometry.coordinates;
const distance = map.distance([lat, lng], [deviceLat, deviceLng]);
if (distance <= radiusMeters) {
return device;
}
}
return null;
};
// Check if device has available ports
const hasAvailablePorts = (device: any): boolean => {
const portCount = device.properties.port_count || 0;
const usedPorts = device.properties.connections?.length || 0;
return usedPorts < portCount;
};
// Count wireless mesh connections for an AP
const getWirelessMeshCount = (apId: string): number => {
const meshLinks = allItems.filter(item =>
item.type === 'wireless_mesh' &&
(item.properties.start_ap_id === apId || item.properties.end_ap_id === apId)
);
return meshLinks.length;
};
const map = useMapEvents({
click: async (e) => {
if (activeTool === 'select') return;
let { lat, lng } = e.latlng;
let clickedDevice = null;
// Check for nearby device when drawing cables
if (isCableTool && map) {
clickedDevice = findNearbyDevice(lat, lng);
if (clickedDevice) {
// Check if device has available ports
if (!hasAvailablePorts(clickedDevice)) {
const portCount = clickedDevice.properties.port_count || 0;
const usedPorts = clickedDevice.properties.connections?.length || 0;
const deviceName = clickedDevice.properties.name || clickedDevice.type;
alert(`${deviceName} has no available ports (${usedPorts}/${portCount} ports used). Please select a different device or increase the port count.`);
return;
}
// Snap to device center
const [deviceLng, deviceLat] = clickedDevice.geometry.coordinates;
lat = deviceLat;
lng = deviceLng;
// Track device connection
if (!isDrawing) {
setStartDeviceId(clickedDevice.id);
} else {
setEndDeviceId(clickedDevice.id);
}
}
}
// Device placement - single click
if (isDeviceTool) {
try {
await mapItemService.createMapItem(mapId, {
type: activeTool as any,
geometry: {
type: 'Point',
coordinates: [lng, lat],
},
properties: {
name: `${activeTool} at ${lat.toFixed(4)}, ${lng.toFixed(4)}`,
port_count: activeTool === 'switch' ? 5 : activeTool === 'outdoor_ap' ? 1 : 4,
connections: [],
},
});
onItemCreated();
// Reload items for snapping
const items = await mapItemService.getMapItems(mapId);
setAllItems(items);
} catch (error) {
console.error('Failed to create device:', error);
}
return;
}
// Cable drawing - multi-point
if (isCableTool) {
if (!isDrawing) {
setIsDrawing(true);
addDrawingPoint([lat, lng]);
} else {
addDrawingPoint([lat, lng]);
}
}
// Wireless mesh - connect AP to AP only
if (isWirelessTool) {
// Must click on an AP
const ap = findNearbyDevice(lat, lng, 5);
if (!ap || !['indoor_ap', 'outdoor_ap'].includes(ap.type)) {
alert('Wireless mesh can only connect between Access Points. Please click on an AP.');
return;
}
// Check wireless mesh connection limit (max 4 per AP)
const meshCount = getWirelessMeshCount(ap.id);
if (meshCount >= 4) {
const apName = ap.properties.name || ap.type;
alert(`${apName} already has the maximum of 4 wireless mesh connections. Please select a different AP.`);
return;
}
if (!isDrawing) {
// First AP clicked
setIsDrawing(true);
setStartDeviceId(ap.id);
const [apLng, apLat] = ap.geometry.coordinates;
addDrawingPoint([apLat, apLng]);
} else if (drawingPoints.length === 1) {
// Second AP clicked - check if it's different from first
if (ap.id === startDeviceId) {
alert('Cannot create wireless mesh to the same Access Point. Please select a different AP.');
return;
}
// Second AP clicked - finish immediately with both points
const [apLng, apLat] = ap.geometry.coordinates;
const secondPoint: [number, number] = [apLat, apLng];
await finishWirelessMesh(ap.id, secondPoint);
}
}
},
mousemove: (e) => {
// Update cursor position for preview line
if (isDrawing && (isCableTool || isWirelessTool)) {
setCursorPosition([e.latlng.lat, e.latlng.lng]);
}
},
contextmenu: async (e) => {
// Right-click to finish cable drawing
if (isCableTool && isDrawing && drawingPoints.length >= 2) {
e.originalEvent.preventDefault();
e.originalEvent.stopPropagation();
await finishCable();
return false;
}
if (!isDrawing) {
e.originalEvent.preventDefault();
}
},
});
useEffect(() => {
// Listen for Escape key globally
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isDrawing) {
resetDrawing();
setCursorPosition(null);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isDrawing, resetDrawing]);
const finishCable = async () => {
if (drawingPoints.length < 2) return;
try {
const cableType = activeTool as CableType;
await mapItemService.createMapItem(mapId, {
type: 'cable',
geometry: {
type: 'LineString',
coordinates: drawingPoints.map(([lat, lng]) => [lng, lat]),
},
properties: {
cable_type: cableType,
name: `${cableType} cable`,
start_device_id: startDeviceId,
end_device_id: endDeviceId,
},
});
onItemCreated();
resetDrawing();
setCursorPosition(null);
setStartDeviceId(null);
setEndDeviceId(null);
// Reload items
const items = await mapItemService.getMapItems(mapId);
setAllItems(items);
} catch (error) {
console.error('Failed to create cable:', error);
}
};
const finishWirelessMesh = async (endApId: string, secondPoint: [number, number]) => {
if (drawingPoints.length !== 1) {
console.error('Wireless mesh requires exactly 1 starting point');
return;
}
try {
// Create line with first point from state and second point from parameter
const coordinates = [
[drawingPoints[0][1], drawingPoints[0][0]], // First point: [lng, lat]
[secondPoint[1], secondPoint[0]], // Second point: [lng, lat]
];
await mapItemService.createMapItem(mapId, {
type: 'wireless_mesh',
geometry: {
type: 'LineString',
coordinates: coordinates,
},
properties: {
name: 'Wireless mesh link',
start_ap_id: startDeviceId,
end_ap_id: endApId,
},
});
onItemCreated();
// Reset drawing state but keep the wireless mesh tool active
resetDrawing();
setCursorPosition(null);
setStartDeviceId(null);
setEndDeviceId(null);
// Reload items
const items = await mapItemService.getMapItems(mapId);
setAllItems(items);
} catch (error) {
console.error('Failed to create wireless mesh:', error);
// Reset on error too
resetDrawing();
setCursorPosition(null);
setStartDeviceId(null);
setEndDeviceId(null);
}
};
// Render drawing preview
if (isDrawing && drawingPoints.length > 0) {
const color = isCableTool
? CABLE_COLORS[activeTool as CableType]
: isWirelessTool
? '#10B981'
: '#6B7280';
const dashArray = isWirelessTool ? '10, 10' : undefined;
// Create preview line from last point to cursor
const previewPositions = cursorPosition
? [...drawingPoints, cursorPosition]
: drawingPoints;
return (
<>
{/* Main line connecting all points */}
{drawingPoints.length > 1 && (
<Polyline
positions={drawingPoints}
color={color}
weight={3}
dashArray={dashArray}
opacity={0.8}
/>
)}
{/* Preview line from last point to cursor */}
{cursorPosition && drawingPoints.length > 0 && (
<Polyline
positions={[drawingPoints[drawingPoints.length - 1], cursorPosition]}
color={color}
weight={3}
dashArray="5, 5"
opacity={0.5}
/>
)}
{/* Markers at each point */}
{drawingPoints.map((point, idx) => (
<Marker key={idx} position={point} />
))}
</>
);
}
return null;
}

View File

@@ -0,0 +1,387 @@
import { useState, useEffect, useRef } from 'react';
import { mapItemService } from '../../services/mapItemService';
interface ItemContextMenuProps {
item: any;
position: { x: number; y: number };
onClose: () => void;
onUpdate: () => void;
}
export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemContextMenuProps) {
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [showNotesDialog, setShowNotesDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showPortConfigDialog, setShowPortConfigDialog] = useState(false);
const [newName, setNewName] = useState(item.properties.name || '');
const [notes, setNotes] = useState(item.properties.notes || '');
const [imageData, setImageData] = useState<string | null>(item.properties.image || null);
const [portCount, setPortCount] = useState(item.properties.port_count || 5);
const [deleteConnectedCables, setDeleteConnectedCables] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const isSwitch = item.type === 'switch';
const isDevice = ['switch', 'indoor_ap', 'outdoor_ap'].includes(item.type);
const hasConnections = item.properties.connections && item.properties.connections.length > 0;
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [onClose]);
const handleDelete = async () => {
try {
// If device with connections and user wants to delete connected cables
if (isDevice && deleteConnectedCables && hasConnections) {
// First delete all connected cables
const { mapItemService: itemService } = await import('../../services/mapItemService');
const allItems = await itemService.getMapItems(item.map_id);
// Find all cables connected to this device
const connectedCableIds = item.properties.connections.map((conn: any) => conn.cable_id);
const cablesToDelete = allItems.filter((i: any) =>
i.type === 'cable' && connectedCableIds.includes(i.id)
);
// Delete each cable
for (const cable of cablesToDelete) {
await itemService.deleteMapItem(item.map_id, cable.id);
}
}
// Delete the device/item itself
await mapItemService.deleteMapItem(item.map_id, item.id);
onUpdate();
onClose();
} catch (error) {
console.error('Failed to delete item:', error);
alert('Failed to delete item');
}
};
const handleRename = async () => {
try {
await mapItemService.updateMapItem(item.map_id, item.id, {
properties: {
...item.properties,
name: newName,
},
});
onUpdate();
setShowRenameDialog(false);
onClose();
} catch (error) {
console.error('Failed to rename item:', error);
alert('Failed to rename item');
}
};
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Check file size (max 2MB)
if (file.size > 2 * 1024 * 1024) {
alert('Image too large. Please use an image smaller than 2MB.');
return;
}
// Check file type
if (!file.type.startsWith('image/')) {
alert('Please select an image file.');
return;
}
const reader = new FileReader();
reader.onload = () => {
setImageData(reader.result as string);
};
reader.readAsDataURL(file);
};
const handleRemoveImage = () => {
setImageData(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleSaveNotes = async () => {
try {
await mapItemService.updateMapItem(item.map_id, item.id, {
properties: {
...item.properties,
notes: notes,
image: imageData,
},
});
onUpdate();
setShowNotesDialog(false);
onClose();
} catch (error) {
console.error('Failed to save notes:', error);
alert('Failed to save notes');
}
};
const handleSavePortConfig = async () => {
try {
await mapItemService.updateMapItem(item.map_id, item.id, {
properties: {
...item.properties,
port_count: portCount,
},
});
onUpdate();
setShowPortConfigDialog(false);
onClose();
} catch (error) {
console.error('Failed to save port configuration:', error);
alert('Failed to save port configuration');
}
};
if (showPortConfigDialog) {
return (
<div
ref={menuRef}
className="fixed bg-white rounded-lg shadow-xl border border-gray-200 p-4"
style={{ left: position.x, top: position.y, zIndex: 10000, minWidth: '250px' }}
>
<h3 className="font-semibold mb-2">Configure Ports</h3>
<label className="block text-sm text-gray-600 mb-2">
Total number of ports:
</label>
<input
type="number"
min="1"
max="96"
value={portCount}
onChange={(e) => setPortCount(parseInt(e.target.value) || 1)}
className="w-full px-3 py-2 border border-gray-300 rounded mb-3"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleSavePortConfig();
if (e.key === 'Escape') { setShowPortConfigDialog(false); onClose(); }
}}
/>
<div className="text-xs text-gray-500 mb-3">
Currently used: {item.properties.connections?.length || 0} ports
</div>
<div className="flex gap-2">
<button
onClick={handleSavePortConfig}
className="flex-1 px-3 py-1.5 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Save
</button>
<button
onClick={() => { setShowPortConfigDialog(false); onClose(); }}
className="flex-1 px-3 py-1.5 bg-gray-300 text-gray-700 rounded hover:bg-gray-400"
>
Cancel
</button>
</div>
</div>
);
}
if (showDeleteDialog) {
return (
<div
ref={menuRef}
className="fixed bg-white rounded-lg shadow-xl border border-gray-200 p-4"
style={{ left: position.x, top: position.y, zIndex: 10000, minWidth: '300px' }}
>
<h3 className="font-semibold text-lg mb-2">Delete Item</h3>
<p className="text-gray-600 mb-4">
Are you sure you want to delete <span className="font-semibold">{item.properties.name || item.type}</span>?
{item.type === 'cable' && item.properties.start_device_id && (
<span className="block mt-2 text-sm">This will also remove the connection from the connected devices.</span>
)}
</p>
{/* Checkbox for deleting connected cables */}
{isDevice && hasConnections && (
<label className="flex items-center gap-2 mb-4 cursor-pointer">
<input
type="checkbox"
checked={deleteConnectedCables}
onChange={(e) => setDeleteConnectedCables(e.target.checked)}
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">
Also delete {item.properties.connections.length} connected cable{item.properties.connections.length !== 1 ? 's' : ''}
</span>
</label>
)}
<div className="flex gap-2">
<button
onClick={handleDelete}
className="flex-1 px-3 py-2 bg-red-600 text-white rounded hover:bg-red-700 font-medium"
>
Delete
</button>
<button
onClick={() => { setShowDeleteDialog(false); setDeleteConnectedCables(false); onClose(); }}
className="flex-1 px-3 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400"
>
Cancel
</button>
</div>
</div>
);
}
if (showRenameDialog) {
return (
<div
ref={menuRef}
className="fixed bg-white rounded-lg shadow-xl border border-gray-200 p-4"
style={{ left: position.x, top: position.y, zIndex: 10000, minWidth: '250px' }}
>
<h3 className="font-semibold mb-2">Rename Item</h3>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded mb-3"
placeholder="Enter new name"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleRename();
if (e.key === 'Escape') { setShowRenameDialog(false); onClose(); }
}}
/>
<div className="flex gap-2">
<button
onClick={handleRename}
className="flex-1 px-3 py-1.5 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Save
</button>
<button
onClick={() => { setShowRenameDialog(false); onClose(); }}
className="flex-1 px-3 py-1.5 bg-gray-300 text-gray-700 rounded hover:bg-gray-400"
>
Cancel
</button>
</div>
</div>
);
}
if (showNotesDialog) {
return (
<div
ref={menuRef}
className="fixed bg-white rounded-lg shadow-xl border border-gray-200 p-4"
style={{ left: position.x, top: position.y, zIndex: 10000, minWidth: '350px', maxWidth: '400px' }}
>
<h3 className="font-semibold mb-2">Edit Notes</h3>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded mb-3"
placeholder="Enter notes"
rows={4}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Escape') { setShowNotesDialog(false); onClose(); }
}}
/>
{/* Image upload section */}
<div className="mb-3">
<label className="block text-sm font-medium text-gray-700 mb-2">
Attach Image (optional)
</label>
{imageData ? (
<div className="relative">
<img
src={imageData}
alt="Attached"
className="w-full rounded border border-gray-300 mb-2"
style={{ maxHeight: '200px', objectFit: 'contain' }}
/>
<button
onClick={handleRemoveImage}
className="absolute top-2 right-2 bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-700"
>
×
</button>
</div>
) : (
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
/>
)}
<p className="text-xs text-gray-500 mt-1">Max size: 2MB</p>
</div>
<div className="flex gap-2">
<button
onClick={handleSaveNotes}
className="flex-1 px-3 py-1.5 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Save
</button>
<button
onClick={() => { setShowNotesDialog(false); onClose(); }}
className="flex-1 px-3 py-1.5 bg-gray-300 text-gray-700 rounded hover:bg-gray-400"
>
Cancel
</button>
</div>
</div>
);
}
return (
<div
ref={menuRef}
className="fixed bg-white rounded-lg shadow-xl border border-gray-200 py-1"
style={{ left: position.x, top: position.y, zIndex: 10000, minWidth: '180px' }}
>
<button
onClick={() => setShowRenameDialog(true)}
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100"
>
Rename
</button>
<button
onClick={() => setShowNotesDialog(true)}
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100"
>
{item.properties.notes ? 'Edit Notes' : 'Add Notes'}
</button>
{isSwitch && (
<button
onClick={() => setShowPortConfigDialog(true)}
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100"
>
Configure Ports
</button>
)}
<div className="border-t border-gray-200 my-1"></div>
<button
onClick={() => setShowDeleteDialog(true)}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50"
>
Delete
</button>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { useState } from 'react';
interface LayerInfo {
name: string;
url: string;
attribution: string;
maxZoom: number;
maxNativeZoom?: number;
}
interface LayerSwitcherProps {
activeLayer: string;
onLayerChange: (layer: string) => void;
layers: Record<string, LayerInfo>;
}
export function LayerSwitcher({ activeLayer, onLayerChange, layers }: LayerSwitcherProps) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="bg-white shadow-md rounded-lg px-4 py-2 hover:bg-gray-50 flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
<span className="text-sm font-medium">
{layers[activeLayer]?.name || 'Map Layer'}
</span>
<svg
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1">
{Object.entries(layers).map(([key, layer]) => (
<button
key={key}
onClick={() => {
onLayerChange(key);
setIsOpen(false);
}}
className={`w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center justify-between ${
activeLayer === key ? 'bg-blue-50 text-blue-700' : 'text-gray-700'
}`}
>
<span>{layer.name}</span>
{activeLayer === key && (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
)}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,422 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { Polyline, Marker, Popup, Circle, useMapEvents } from 'react-leaflet';
import L from 'leaflet';
import { mapItemService } from '../../services/mapItemService';
import { CABLE_COLORS, type MapItem, type CableType } from '../../types/mapItem';
import { ItemContextMenu } from './ItemContextMenu';
import { useDrawingStore } from '../../stores/drawingStore';
interface MapItemsLayerProps {
mapId: string;
refreshTrigger: number;
}
// Custom marker icons for devices using CSS
const switchIcon = new L.DivIcon({
html: `<div class="device-icon switch-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<rect x="2" y="4" width="20" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
<circle cx="6" cy="10" r="1.5"/>
<circle cx="10" cy="10" r="1.5"/>
<circle cx="14" cy="10" r="1.5"/>
<circle cx="18" cy="10" r="1.5"/>
<circle cx="6" cy="14" r="1.5"/>
<circle cx="10" cy="14" r="1.5"/>
<circle cx="14" cy="14" r="1.5"/>
<circle cx="18" cy="14" r="1.5"/>
</svg>
</div>`,
className: 'custom-device-marker',
iconSize: [40, 40],
iconAnchor: [20, 40],
});
const indoorApIcon = new L.DivIcon({
html: `<div class="device-icon ap-indoor-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" opacity="0.3"/>
<circle cx="12" cy="12" r="3"/>
<path d="M12 6c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm0 10c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4z"/>
</svg>
</div>`,
className: 'custom-device-marker',
iconSize: [40, 40],
iconAnchor: [20, 40],
});
const outdoorApIcon = new L.DivIcon({
html: `<div class="device-icon ap-outdoor-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9z"/>
<path d="M9 17l3 3 3-3c-1.65-1.66-4.34-1.66-6 0z"/>
<path d="M5 13l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z"/>
</svg>
</div>`,
className: 'custom-device-marker',
iconSize: [40, 40],
iconAnchor: [20, 40],
});
export function MapItemsLayer({ mapId, refreshTrigger }: MapItemsLayerProps) {
const [items, setItems] = useState<MapItem[]>([]);
const [loading, setLoading] = useState(true);
const [contextMenu, setContextMenu] = useState<{
item: MapItem;
position: { x: number; y: number };
} | null>(null);
const { activeTool } = useDrawingStore();
// Check if we're in drawing mode (should suppress popups)
const isCableTool = ['fiber', 'cat6', 'cat6_poe'].includes(activeTool);
const isWirelessTool = activeTool === 'wireless_mesh';
const shouldSuppressPopups = isCableTool || isWirelessTool;
useEffect(() => {
loadItems();
}, [mapId, refreshTrigger]);
const loadItems = async () => {
try {
setLoading(true);
const data = await mapItemService.getMapItems(mapId);
console.log('Loaded items:', data);
// Log devices with their connections
data.forEach(item => {
if (['switch', 'indoor_ap', 'outdoor_ap'].includes(item.type)) {
console.log(`Device ${item.type} (${item.id}): ${item.properties.connections?.length || 0} / ${item.properties.port_count} ports`);
}
});
setItems(data);
} catch (error) {
console.error('Failed to load map items:', error);
} finally {
setLoading(false);
}
};
// Close context menu on any map click
useMapEvents({
click: () => setContextMenu(null),
contextmenu: () => {}, // Prevent default map context menu
});
const getDeviceIcon = (type: string) => {
switch (type) {
case 'switch':
return switchIcon;
case 'indoor_ap':
return indoorApIcon;
case 'outdoor_ap':
return outdoorApIcon;
default:
return undefined;
}
};
if (loading) return null;
return (
<>
{items.map((item) => {
// Render cables
if (item.type === 'cable' && item.geometry.type === 'LineString') {
const positions = item.geometry.coordinates.map(
([lng, lat]) => [lat, lng] as [number, number]
);
const cableType = item.properties.cable_type as CableType;
const color = CABLE_COLORS[cableType] || '#6B7280';
// Find connected devices
const startDevice = item.properties.start_device_id
? items.find(i => i.id === item.properties.start_device_id)
: null;
const endDevice = item.properties.end_device_id
? items.find(i => i.id === item.properties.end_device_id)
: null;
return (
<div key={item.id}>
<Polyline
positions={positions}
color={color}
weight={3}
opacity={0.8}
eventHandlers={{
contextmenu: (e) => {
L.DomEvent.stopPropagation(e);
setContextMenu({
item,
position: { x: e.originalEvent.clientX, y: e.originalEvent.clientY }
});
},
}}
>
{!shouldSuppressPopups && (
<Popup>
<div className="text-sm" style={{ minWidth: '200px' }}>
<div className="font-semibold">{item.properties.name || 'Cable'}</div>
<div className="text-gray-600">Type: {cableType}</div>
{startDevice && (
<div className="text-gray-600 mt-1">
From: {startDevice.properties.name || startDevice.type}
</div>
)}
{endDevice && (
<div className="text-gray-600">
To: {endDevice.properties.name || endDevice.type}
</div>
)}
{item.properties.notes && (
<div className="text-gray-600 mt-2 pt-2 border-t border-gray-200">
{item.properties.notes}
</div>
)}
{item.properties.image && (
<div className="mt-2">
<img
src={item.properties.image}
alt="Attachment"
className="w-full rounded border border-gray-200"
style={{ maxHeight: '150px', objectFit: 'contain' }}
/>
</div>
)}
</div>
</Popup>
)}
</Polyline>
{/* Show circles at cable bend points (not first/last) */}
{positions.slice(1, -1).map((pos, idx) => (
<Circle
key={`${item.id}-bend-${idx}`}
center={pos}
radius={1}
pathOptions={{
color: color,
fillColor: color,
fillOpacity: 0.3,
weight: 2,
}}
/>
))}
</div>
);
}
// Render wireless mesh
if (item.type === 'wireless_mesh' && item.geometry.type === 'LineString') {
const positions = item.geometry.coordinates.map(
([lng, lat]) => [lat, lng] as [number, number]
);
// Find connected APs
const startAp = item.properties.start_ap_id
? items.find(i => i.id === item.properties.start_ap_id)
: null;
const endAp = item.properties.end_ap_id
? items.find(i => i.id === item.properties.end_ap_id)
: null;
return (
<Polyline
key={item.id}
positions={positions}
color="#10B981"
weight={3}
opacity={0.8}
dashArray="10, 10"
eventHandlers={{
contextmenu: (e) => {
L.DomEvent.stopPropagation(e);
setContextMenu({
item,
position: { x: e.originalEvent.clientX, y: e.originalEvent.clientY }
});
},
}}
>
{!shouldSuppressPopups && (
<Popup>
<div className="text-sm" style={{ minWidth: '200px' }}>
<div className="font-semibold">{item.properties.name || 'Wireless Mesh'}</div>
{startAp && (
<div className="text-gray-600 mt-1">
From: {startAp.properties.name || startAp.type}
</div>
)}
{endAp && (
<div className="text-gray-600">
To: {endAp.properties.name || endAp.type}
</div>
)}
{item.properties.notes && (
<div className="text-gray-600 mt-2 pt-2 border-t border-gray-200">
{item.properties.notes}
</div>
)}
{item.properties.image && (
<div className="mt-2">
<img
src={item.properties.image}
alt="Attachment"
className="w-full rounded border border-gray-200"
style={{ maxHeight: '150px', objectFit: 'contain' }}
/>
</div>
)}
</div>
</Popup>
)}
</Polyline>
);
}
// Render devices
if (
['switch', 'indoor_ap', 'outdoor_ap'].includes(item.type) &&
item.geometry.type === 'Point'
) {
const [lng, lat] = item.geometry.coordinates;
const position: [number, number] = [lat, lng];
const icon = getDeviceIcon(item.type);
return (
<Marker
key={item.id}
position={position}
icon={icon}
eventHandlers={{
contextmenu: (e) => {
L.DomEvent.stopPropagation(e);
setContextMenu({
item,
position: { x: e.originalEvent.clientX, y: e.originalEvent.clientY }
});
},
}}
>
{!shouldSuppressPopups && (
<Popup>
<div className="text-sm" style={{ minWidth: '200px' }}>
<div className="font-semibold">{item.properties.name || item.type}</div>
<div className="text-gray-600">Type: {item.type}</div>
{item.properties.port_count && (
<div className="text-gray-600">
Ports: {item.properties.connections?.length || 0} / {item.properties.port_count}
</div>
)}
{/* Show port connections details */}
{item.properties.connections && item.properties.connections.length > 0 && (
<div className="mt-2 pt-2 border-t border-gray-200">
<div className="font-semibold text-gray-700 mb-1">Port Connections:</div>
{item.properties.connections
.sort((a: any, b: any) => a.port_number - b.port_number)
.map((conn: any) => {
const cable = items.find(i => i.id === conn.cable_id);
if (!cable) return null;
// Find the other device connected to this cable
const otherDeviceId = cable.properties.start_device_id === item.id
? cable.properties.end_device_id
: cable.properties.start_device_id;
const otherDevice = otherDeviceId
? items.find(i => i.id === otherDeviceId)
: null;
const cableType = cable.properties.cable_type;
return (
<div key={conn.cable_id} className="text-xs text-gray-600 ml-2">
Port {conn.port_number} {otherDevice
? `${otherDevice.properties.name || otherDevice.type} (${cableType})`
: `${cableType} cable`}
</div>
);
})}
</div>
)}
{/* Show wireless mesh count for APs */}
{['indoor_ap', 'outdoor_ap'].includes(item.type) && (
<div className="text-gray-600">
Wireless mesh: {items.filter(i =>
i.type === 'wireless_mesh' &&
(i.properties.start_ap_id === item.id || i.properties.end_ap_id === item.id)
).length} / 4
</div>
)}
{/* Show wireless mesh connections details for APs */}
{['indoor_ap', 'outdoor_ap'].includes(item.type) && (() => {
const meshLinks = items.filter(i =>
i.type === 'wireless_mesh' &&
(i.properties.start_ap_id === item.id || i.properties.end_ap_id === item.id)
);
if (meshLinks.length > 0) {
return (
<div className="mt-2 pt-2 border-t border-gray-200">
<div className="font-semibold text-gray-700 mb-1">Mesh Connections:</div>
{meshLinks.map((mesh) => {
const otherApId = mesh.properties.start_ap_id === item.id
? mesh.properties.end_ap_id
: mesh.properties.start_ap_id;
const otherAp = otherApId
? items.find(i => i.id === otherApId)
: null;
return (
<div key={mesh.id} className="text-xs text-gray-600 ml-2">
{otherAp ? (otherAp.properties.name || otherAp.type) : 'Unknown AP'}
</div>
);
})}
</div>
);
}
return null;
})()}
{item.properties.notes && (
<div className="text-gray-600 mt-2 pt-2 border-t border-gray-200">
{item.properties.notes}
</div>
)}
{item.properties.image && (
<div className="mt-2">
<img
src={item.properties.image}
alt="Attachment"
className="w-full rounded border border-gray-200"
style={{ maxHeight: '150px', objectFit: 'contain' }}
/>
</div>
)}
</div>
</Popup>
)}
</Marker>
);
}
return null;
})}
{/* Context menu rendered outside map using portal */}
{contextMenu && createPortal(
<ItemContextMenu
item={contextMenu.item}
position={contextMenu.position}
onClose={() => setContextMenu(null)}
onUpdate={loadItems}
/>,
document.body
)}
</>
);
}

View File

@@ -0,0 +1,142 @@
import { useState, useEffect } from 'react';
import { useMapStore } from '../../stores/mapStore';
import { mapService } from '../../services/mapService';
interface MapListSidebarProps {
onSelectMap: (mapId: string) => void;
selectedMapId: string | null;
}
export function MapListSidebar({ onSelectMap, selectedMapId }: MapListSidebarProps) {
const { maps, setMaps, addMap, removeMap, setLoading, setError } = useMapStore();
const [isCreating, setIsCreating] = useState(false);
const [newMapName, setNewMapName] = useState('');
useEffect(() => {
loadMaps();
}, []);
const loadMaps = async () => {
setLoading(true);
try {
const data = await mapService.getUserMaps();
setMaps(data);
} catch (error: any) {
setError(error.response?.data?.detail || 'Failed to load maps');
} finally {
setLoading(false);
}
};
const handleCreateMap = async () => {
if (!newMapName.trim()) return;
try {
const newMap = await mapService.createMap({ name: newMapName });
addMap(newMap);
setNewMapName('');
setIsCreating(false);
onSelectMap(newMap.id);
} catch (error: any) {
setError(error.response?.data?.detail || 'Failed to create map');
}
};
const handleDeleteMap = async (mapId: string) => {
if (!confirm('Are you sure you want to delete this map?')) return;
try {
await mapService.deleteMap(mapId);
removeMap(mapId);
} catch (error: any) {
setError(error.response?.data?.detail || 'Failed to delete map');
}
};
return (
<div className="w-80 bg-white border-r border-gray-200 flex flex-col">
<div className="p-4 border-b border-gray-200">
<h2 className="text-lg font-semibold mb-3">My Maps</h2>
{!isCreating ? (
<button
onClick={() => setIsCreating(true)}
className="w-full px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
+ New Map
</button>
) : (
<div className="space-y-2">
<input
type="text"
value={newMapName}
onChange={(e) => setNewMapName(e.target.value)}
placeholder="Map name"
className="w-full px-3 py-2 border border-gray-300 rounded"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && handleCreateMap()}
/>
<div className="flex gap-2">
<button
onClick={handleCreateMap}
className="flex-1 px-3 py-1 bg-green-600 text-white rounded hover:bg-green-700 text-sm"
>
Create
</button>
<button
onClick={() => {
setIsCreating(false);
setNewMapName('');
}}
className="flex-1 px-3 py-1 bg-gray-300 text-gray-700 rounded hover:bg-gray-400 text-sm"
>
Cancel
</button>
</div>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto">
{maps.length === 0 ? (
<div className="p-4 text-center text-gray-500 text-sm">
No maps yet. Create your first map!
</div>
) : (
<div className="divide-y divide-gray-200">
{maps.map((map) => (
<div
key={map.id}
className={`p-4 cursor-pointer hover:bg-gray-50 ${
selectedMapId === map.id ? 'bg-blue-50 border-l-4 border-blue-600' : ''
}`}
onClick={() => onSelectMap(map.id)}
>
<div className="flex justify-between items-start">
<div className="flex-1">
<h3 className="font-medium text-gray-900">{map.name}</h3>
{map.description && (
<p className="text-sm text-gray-600 mt-1">{map.description}</p>
)}
<p className="text-xs text-gray-400 mt-1">
{new Date(map.updated_at).toLocaleDateString()}
</p>
</div>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteMap(map.id);
}}
className="text-red-600 hover:text-red-800 text-sm ml-2"
>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import { useState, useEffect } from 'react';
import { MapContainer, TileLayer, useMap } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import { Toolbar } from './Toolbar';
import { LayerSwitcher } from './LayerSwitcher';
import { DrawingHandler } from './DrawingHandler';
import { MapItemsLayer } from './MapItemsLayer';
interface MapViewProps {
mapId: string | null;
}
type MapLayer = 'osm' | 'google' | 'esri';
const MAP_LAYERS = {
osm: {
name: 'OpenStreetMap',
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 25,
},
google: {
name: 'Google Satellite',
url: 'https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}',
attribution: '&copy; Google',
maxZoom: 25,
maxNativeZoom: 22,
},
esri: {
name: 'ESRI Satellite',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attribution: 'Tiles &copy; Esri',
maxZoom: 25,
},
};
function MapController() {
const map = useMap();
useEffect(() => {
// Invalidate size when map becomes visible
setTimeout(() => {
map.invalidateSize();
}, 100);
}, [map]);
return null;
}
export function MapView({ mapId }: MapViewProps) {
const [activeLayer, setActiveLayer] = useState<MapLayer>('osm');
const [refreshTrigger, setRefreshTrigger] = useState(0);
const handleItemCreated = () => {
// Trigger refresh of map items
setRefreshTrigger((prev) => prev + 1);
};
if (!mapId) {
return (
<div className="flex-1 flex items-center justify-center bg-gray-100">
<div className="text-center">
<p className="text-gray-500 text-lg">Select a map from the sidebar to get started</p>
<p className="text-gray-400 text-sm mt-2">or create a new one</p>
</div>
</div>
);
}
const layer = MAP_LAYERS[activeLayer];
return (
<>
{/* Toolbar for drawing tools */}
<div style={{ position: 'fixed', left: '220px', top: '70px', zIndex: 9999 }}>
<Toolbar mapId={mapId} />
</div>
{/* Layer switcher */}
<div style={{ position: 'fixed', right: '20px', top: '70px', zIndex: 9999 }}>
<LayerSwitcher
activeLayer={activeLayer}
onLayerChange={setActiveLayer}
layers={MAP_LAYERS}
/>
</div>
<div className="flex-1 relative">
<MapContainer
center={[0, 0]}
zoom={2}
className="h-full w-full"
style={{ background: '#f0f0f0' }}
>
<MapController />
<TileLayer
key={activeLayer}
url={layer.url}
attribution={layer.attribution}
maxZoom={layer.maxZoom}
maxNativeZoom={layer.maxNativeZoom}
/>
{/* Drawing handler for creating new items */}
<DrawingHandler mapId={mapId} onItemCreated={handleItemCreated} />
{/* Render existing map items */}
<MapItemsLayer mapId={mapId} refreshTrigger={refreshTrigger} />
</MapContainer>
</div>
</>
);
}

View File

@@ -0,0 +1,99 @@
import { useDrawingStore } from '../../stores/drawingStore';
import type { DrawingTool } from '../../types/mapItem';
import { CABLE_COLORS, CABLE_LABELS } from '../../types/mapItem';
interface ToolbarProps {
mapId: string;
}
interface ToolButton {
id: DrawingTool;
label: string;
icon: string;
color?: string;
description: string;
}
const TOOLS: ToolButton[] = [
{
id: 'select',
label: 'Select',
icon: '👆',
description: 'Select and edit items',
},
{
id: 'fiber',
label: 'Fiber',
icon: '━',
color: CABLE_COLORS.fiber,
description: CABLE_LABELS.fiber,
},
{
id: 'cat6',
label: 'Cat6',
icon: '━',
color: CABLE_COLORS.cat6,
description: CABLE_LABELS.cat6,
},
{
id: 'cat6_poe',
label: 'Cat6 PoE',
icon: '━',
color: CABLE_COLORS.cat6_poe,
description: CABLE_LABELS.cat6_poe,
},
{
id: 'switch',
label: 'Switch',
icon: '⚡',
description: 'Network Switch',
},
{
id: 'indoor_ap',
label: 'Indoor AP',
icon: '📡',
description: 'Indoor Access Point',
},
{
id: 'outdoor_ap',
label: 'Outdoor AP',
icon: '📶',
description: 'Outdoor Access Point',
},
{
id: 'wireless_mesh',
label: 'Wireless',
icon: '⚡',
color: '#10B981',
description: 'Wireless Mesh Link',
},
];
export function Toolbar({ mapId }: ToolbarProps) {
const { activeTool, setActiveTool } = useDrawingStore();
return (
<div className="bg-white shadow-lg rounded-lg p-2 space-y-1" style={{ minWidth: '150px' }}>
{TOOLS.map((tool) => (
<button
key={tool.id}
onClick={() => setActiveTool(tool.id)}
className={`w-full px-3 py-2 rounded text-left flex items-center gap-2 transition-colors ${
activeTool === tool.id
? 'bg-blue-100 text-blue-700 font-medium'
: 'hover:bg-gray-100 text-gray-700'
}`}
title={tool.description}
>
<span
className="text-lg"
style={tool.color ? { color: tool.color } : undefined}
>
{tool.icon}
</span>
<span className="text-sm">{tool.label}</span>
</button>
))}
</div>
);
}

56
public/src/index.css Normal file
View File

@@ -0,0 +1,56 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Leaflet CSS will be imported in main.tsx */
/* Custom Leaflet marker styles */
.custom-marker {
background: transparent !important;
border: none !important;
display: flex;
align-items: center;
justify-content: center;
}
.custom-marker div {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
}
/* Device marker icons */
.custom-device-marker {
background: transparent !important;
border: none !important;
}
.device-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: white;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 3px solid;
}
.device-icon svg {
width: 24px;
height: 24px;
}
.switch-icon {
border-color: #8B5CF6;
color: #8B5CF6;
}
.ap-indoor-icon {
border-color: #10B981;
color: #10B981;
}
.ap-outdoor-icon {
border-color: #F59E0B;
color: #F59E0B;
}

10
public/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,20 @@
import { useState } from 'react';
import { Layout } from '../components/layout/Layout';
import { MapListSidebar } from '../components/map/MapListSidebar';
import { MapView } from '../components/map/MapView';
export function Dashboard() {
const [selectedMapId, setSelectedMapId] = useState<string | null>(null);
return (
<Layout>
<div className="flex h-full">
<MapListSidebar
onSelectMap={setSelectedMapId}
selectedMapId={selectedMapId}
/>
<MapView mapId={selectedMapId} />
</div>
</Layout>
);
}

View File

@@ -0,0 +1,38 @@
import axios from 'axios';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
export const apiClient = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor to add auth token
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor to handle 401 errors
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Clear tokens and redirect to login
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);

View File

@@ -0,0 +1,32 @@
import { apiClient } from './api';
import type { LoginRequest, TokenResponse, User } from '../types/auth';
export const authService = {
async login(credentials: LoginRequest): Promise<TokenResponse> {
const response = await apiClient.post<TokenResponse>('/api/auth/login', credentials);
return response.data;
},
async getCurrentUser(): Promise<User> {
const response = await apiClient.get<User>('/api/auth/me');
return response.data;
},
logout() {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
},
saveTokens(tokens: TokenResponse) {
localStorage.setItem('access_token', tokens.access_token);
localStorage.setItem('refresh_token', tokens.refresh_token);
},
getAccessToken(): string | null {
return localStorage.getItem('access_token');
},
isAuthenticated(): boolean {
return !!this.getAccessToken();
},
};

View File

@@ -0,0 +1,28 @@
import { apiClient } from './api';
import type { MapItem, MapItemCreate, MapItemUpdate } from '../types/mapItem';
export const mapItemService = {
async getMapItems(mapId: string): Promise<MapItem[]> {
const response = await apiClient.get<MapItem[]>(`/api/maps/${mapId}/items`);
return response.data;
},
async getMapItem(mapId: string, itemId: string): Promise<MapItem> {
const response = await apiClient.get<MapItem>(`/api/maps/${mapId}/items/${itemId}`);
return response.data;
},
async createMapItem(mapId: string, data: MapItemCreate): Promise<MapItem> {
const response = await apiClient.post<MapItem>(`/api/maps/${mapId}/items`, data);
return response.data;
},
async updateMapItem(mapId: string, itemId: string, data: MapItemUpdate): Promise<MapItem> {
const response = await apiClient.patch<MapItem>(`/api/maps/${mapId}/items/${itemId}`, data);
return response.data;
},
async deleteMapItem(mapId: string, itemId: string): Promise<void> {
await apiClient.delete(`/api/maps/${mapId}/items/${itemId}`);
},
};

View File

@@ -0,0 +1,38 @@
import { apiClient } from './api';
import type { Map, MapCreate, MapUpdate } from '../types/map';
export const mapService = {
async getUserMaps(): Promise<Map[]> {
const response = await apiClient.get<Map[]>('/api/maps');
return response.data;
},
async getMap(mapId: string): Promise<Map> {
const response = await apiClient.get<Map>(`/api/maps/${mapId}`);
return response.data;
},
async getPublicMap(): Promise<Map> {
const response = await apiClient.get<Map>('/api/maps/public');
return response.data;
},
async createMap(data: MapCreate): Promise<Map> {
const response = await apiClient.post<Map>('/api/maps', data);
return response.data;
},
async updateMap(mapId: string, data: MapUpdate): Promise<Map> {
const response = await apiClient.patch<Map>(`/api/maps/${mapId}`, data);
return response.data;
},
async deleteMap(mapId: string): Promise<void> {
await apiClient.delete(`/api/maps/${mapId}`);
},
async setDefaultPublic(mapId: string): Promise<Map> {
const response = await apiClient.post<Map>(`/api/maps/${mapId}/set-default-public`);
return response.data;
},
};

View File

@@ -0,0 +1,60 @@
import { create } from 'zustand';
import type { User } from '../types/auth';
import { authService } from '../services/authService';
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
loadUser: () => Promise<void>;
clearError: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isAuthenticated: authService.isAuthenticated(),
isLoading: false,
error: null,
login: async (username, password) => {
set({ isLoading: true, error: null });
try {
const tokens = await authService.login({ username, password });
authService.saveTokens(tokens);
const user = await authService.getCurrentUser();
set({ user, isAuthenticated: true, isLoading: false });
} catch (error: any) {
const message = error.response?.data?.detail || 'Login failed';
set({ error: message, isLoading: false, isAuthenticated: false });
throw error;
}
},
logout: () => {
authService.logout();
set({ user: null, isAuthenticated: false });
},
loadUser: async () => {
if (!authService.isAuthenticated()) {
set({ isAuthenticated: false, user: null });
return;
}
set({ isLoading: true });
try {
const user = await authService.getCurrentUser();
set({ user, isAuthenticated: true, isLoading: false });
} catch (error) {
authService.logout();
set({ user: null, isAuthenticated: false, isLoading: false });
}
},
clearError: () => set({ error: null }),
}));

View File

@@ -0,0 +1,35 @@
import { create } from 'zustand';
import type { DrawingTool } from '../types/mapItem';
interface DrawingState {
activeTool: DrawingTool;
isDrawing: boolean;
drawingPoints: [number, number][];
setActiveTool: (tool: DrawingTool) => void;
setIsDrawing: (isDrawing: boolean) => void;
addDrawingPoint: (point: [number, number]) => void;
clearDrawingPoints: () => void;
resetDrawing: () => void;
}
export const useDrawingStore = create<DrawingState>((set) => ({
activeTool: 'select',
isDrawing: false,
drawingPoints: [],
setActiveTool: (tool) => set({ activeTool: tool, isDrawing: false, drawingPoints: [] }),
setIsDrawing: (isDrawing) => set({ isDrawing }),
addDrawingPoint: (point) => set((state) => ({
drawingPoints: [...state.drawingPoints, point],
})),
clearDrawingPoints: () => set({ drawingPoints: [] }),
resetDrawing: () => set({
isDrawing: false,
drawingPoints: [],
}),
}));

View File

@@ -0,0 +1,49 @@
import { create } from 'zustand';
import type { Map } from '../types/map';
interface MapState {
maps: Map[];
currentMap: Map | null;
isLoading: boolean;
error: string | null;
setMaps: (maps: Map[]) => void;
setCurrentMap: (map: Map | null) => void;
addMap: (map: Map) => void;
updateMap: (mapId: string, updates: Partial<Map>) => void;
removeMap: (mapId: string) => void;
setLoading: (isLoading: boolean) => void;
setError: (error: string | null) => void;
clearError: () => void;
}
export const useMapStore = create<MapState>((set) => ({
maps: [],
currentMap: null,
isLoading: false,
error: null,
setMaps: (maps) => set({ maps }),
setCurrentMap: (map) => set({ currentMap: map }),
addMap: (map) => set((state) => ({ maps: [map, ...state.maps] })),
updateMap: (mapId, updates) => set((state) => ({
maps: state.maps.map((m) => (m.id === mapId ? { ...m, ...updates } : m)),
currentMap: state.currentMap?.id === mapId
? { ...state.currentMap, ...updates }
: state.currentMap,
})),
removeMap: (mapId) => set((state) => ({
maps: state.maps.filter((m) => m.id !== mapId),
currentMap: state.currentMap?.id === mapId ? null : state.currentMap,
})),
setLoading: (isLoading) => set({ isLoading }),
setError: (error) => set({ error }),
clearError: () => set({ error: null }),
}));

25
public/src/types/auth.ts Normal file
View File

@@ -0,0 +1,25 @@
export interface User {
id: string;
username: string;
email: string;
is_admin: boolean;
created_at: string;
updated_at: string;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface TokenResponse {
access_token: string;
refresh_token: string;
token_type: string;
}
export interface UserWithToken extends User {
access_token: string;
refresh_token: string;
token_type: string;
}

19
public/src/types/map.ts Normal file
View File

@@ -0,0 +1,19 @@
export interface Map {
id: string;
name: string;
description: string | null;
owner_id: string;
is_default_public: boolean;
created_at: string;
updated_at: string;
}
export interface MapCreate {
name: string;
description?: string | null;
}
export interface MapUpdate {
name?: string;
description?: string | null;
}

View File

@@ -0,0 +1,75 @@
export type MapItemType = 'cable' | 'switch' | 'indoor_ap' | 'outdoor_ap' | 'wireless_mesh';
export type CableType = 'fiber' | 'cat6' | 'cat6_poe';
export interface MapItem {
id: string;
map_id: string;
type: MapItemType;
geometry: GeoJSON.Geometry;
properties: Record<string, any>;
created_at: string;
updated_at: string;
created_by: string | null;
updated_by: string | null;
}
export interface CableProperties {
cable_type: CableType;
name?: string;
notes?: string;
length_meters?: number;
start_device_id?: string | null;
end_device_id?: string | null;
}
export interface DeviceProperties {
name?: string;
notes?: string;
port_count?: number;
connections?: Array<{
cable_id: string;
port_number: number;
}>;
}
export interface WirelessMeshProperties {
name?: string;
notes?: string;
start_ap_id?: string;
end_ap_id?: string;
}
export interface MapItemCreate {
type: MapItemType;
geometry: GeoJSON.Geometry;
properties: Record<string, any>;
}
export interface MapItemUpdate {
type?: MapItemType;
geometry?: GeoJSON.Geometry;
properties?: Record<string, any>;
}
export type DrawingTool =
| 'select'
| 'fiber'
| 'cat6'
| 'cat6_poe'
| 'switch'
| 'indoor_ap'
| 'outdoor_ap'
| 'wireless_mesh';
export const CABLE_COLORS: Record<CableType, string> = {
fiber: '#3B82F6', // Blue
cat6: '#F97316', // Orange
cat6_poe: '#EF4444', // Red
};
export const CABLE_LABELS: Record<CableType, string> = {
fiber: 'Fiber Cable',
cat6: 'Cat6 Cable',
cat6_poe: 'Cat6 PoE Cable',
};

11
public/tailwind.config.js Normal file
View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

28
public/tsconfig.app.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
public/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
public/tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

11
public/vite.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
host: '0.0.0.0',
},
})

35
requirements.txt Normal file
View File

@@ -0,0 +1,35 @@
# FastAPI and ASGI server
fastapi==0.104.1
uvicorn[standard]==0.24.0
python-multipart==0.0.6
# Database
sqlalchemy==2.0.23
alembic==1.12.1
psycopg2-binary==2.9.9
geoalchemy2==0.14.2
numpy==1.26.4
shapely==2.0.2
# Pydantic for validation
pydantic[email]==2.5.0
pydantic-settings==2.1.0
# Authentication
python-jose[cryptography]==3.3.0
passlib==1.7.4
bcrypt==4.0.1
# WebSocket for real-time collaboration
python-socketio==5.10.0
# CORS middleware
python-dotenv==1.0.0
# Testing
pytest==7.4.3
pytest-asyncio==0.21.1
httpx==0.25.1
# Production server
gunicorn==21.2.0

93
scripts/create_admin.py Normal file
View File

@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""
Script to create an admin user for the ISP Wiremap application.
Usage: python scripts/create_admin.py
"""
import sys
from pathlib import Path
# Add parent directory to path to import app modules
sys.path.append(str(Path(__file__).resolve().parents[1]))
from app.database import SessionLocal
from app.models.user import User
from app.utils.password import hash_password
import getpass
def create_admin_user():
"""Create an admin user interactively."""
print("=" * 50)
print("ISP Wiremap - Create Admin User")
print("=" * 50)
print()
# Get user input
username = input("Enter admin username: ").strip()
if not username:
print("Error: Username cannot be empty")
return
email = input("Enter admin email: ").strip()
if not email:
print("Error: Email cannot be empty")
return
password = getpass.getpass("Enter admin password: ")
if not password:
print("Error: Password cannot be empty")
return
password_confirm = getpass.getpass("Confirm password: ")
if password != password_confirm:
print("Error: Passwords do not match")
return
# Create database session
db = SessionLocal()
try:
# Check if username already exists
existing_user = db.query(User).filter(User.username == username).first()
if existing_user:
print(f"Error: Username '{username}' already exists")
return
# Check if email already exists
existing_email = db.query(User).filter(User.email == email).first()
if existing_email:
print(f"Error: Email '{email}' already exists")
return
# Create admin user
hashed_password = hash_password(password)
admin_user = User(
username=username,
email=email,
password_hash=hashed_password,
is_admin=True
)
db.add(admin_user)
db.commit()
db.refresh(admin_user)
print()
print("=" * 50)
print("Admin user created successfully!")
print(f"Username: {admin_user.username}")
print(f"Email: {admin_user.email}")
print(f"User ID: {admin_user.id}")
print(f"Is Admin: {admin_user.is_admin}")
print("=" * 50)
except Exception as e:
db.rollback()
print(f"Error creating admin user: {e}")
finally:
db.close()
if __name__ == "__main__":
create_admin_user()