feat: Add server management and database migrations
- Add start/stop/restart/status commands to Makefile - Add health endpoint with uptime tracking - Add CLI module (esp32-web command) - Add initial database migration - Listen on all interfaces (0.0.0.0:5500) Bump version to 0.1.1
This commit is contained in:
54
Makefile
54
Makefile
@@ -1,13 +1,56 @@
|
|||||||
.PHONY: build run dev stop logs test migrate clean install
|
.PHONY: build run dev stop logs test migrate clean install start restart status
|
||||||
|
|
||||||
APP_NAME := esp32-web
|
APP_NAME := esp32-web
|
||||||
PORT := 5500
|
PORT := 5500
|
||||||
|
HOST := 0.0.0.0
|
||||||
|
PIDFILE := /tmp/esp32-web.pid
|
||||||
|
LOGFILE := /tmp/esp32-web.log
|
||||||
|
|
||||||
install:
|
install:
|
||||||
pip install -e ".[dev]"
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
|
# Server management
|
||||||
|
start:
|
||||||
|
@if [ -f $(PIDFILE) ] && kill -0 $$(cat $(PIDFILE)) 2>/dev/null; then \
|
||||||
|
echo "Server already running (PID $$(cat $(PIDFILE)))"; \
|
||||||
|
else \
|
||||||
|
echo "Starting server on $(HOST):$(PORT)..."; \
|
||||||
|
nohup flask --app src/esp32_web run --host $(HOST) --port $(PORT) > $(LOGFILE) 2>&1 & \
|
||||||
|
echo $$! > $(PIDFILE); \
|
||||||
|
sleep 1; \
|
||||||
|
echo "Server started (PID $$(cat $(PIDFILE)))"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
stop:
|
||||||
|
@if [ -f $(PIDFILE) ]; then \
|
||||||
|
echo "Stopping server (PID $$(cat $(PIDFILE)))..."; \
|
||||||
|
kill $$(cat $(PIDFILE)) 2>/dev/null || true; \
|
||||||
|
rm -f $(PIDFILE); \
|
||||||
|
echo "Server stopped"; \
|
||||||
|
else \
|
||||||
|
echo "Server not running"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
restart: stop
|
||||||
|
@sleep 1
|
||||||
|
@$(MAKE) start
|
||||||
|
|
||||||
|
status:
|
||||||
|
@if [ -f $(PIDFILE) ] && kill -0 $$(cat $(PIDFILE)) 2>/dev/null; then \
|
||||||
|
echo "Server running (PID $$(cat $(PIDFILE)))"; \
|
||||||
|
curl -s http://localhost:$(PORT)/health 2>/dev/null | \
|
||||||
|
python3 -c "import sys,json; d=json.load(sys.stdin); print(f\"Status: {d['status']}\nUptime: {d['uptime']}\")" 2>/dev/null \
|
||||||
|
|| echo "Health: unreachable"; \
|
||||||
|
else \
|
||||||
|
echo "Server not running"; \
|
||||||
|
rm -f $(PIDFILE) 2>/dev/null; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
logs:
|
||||||
|
@tail -f $(LOGFILE)
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
flask --app src/esp32_web run --port $(PORT) --debug
|
flask --app src/esp32_web run --host $(HOST) --port $(PORT) --debug
|
||||||
|
|
||||||
test:
|
test:
|
||||||
pytest -v
|
pytest -v
|
||||||
@@ -21,20 +64,21 @@ migrate-init:
|
|||||||
migrate-create:
|
migrate-create:
|
||||||
flask --app src/esp32_web db migrate -m "$(msg)"
|
flask --app src/esp32_web db migrate -m "$(msg)"
|
||||||
|
|
||||||
|
# Container targets
|
||||||
build:
|
build:
|
||||||
podman build -t $(APP_NAME) .
|
podman build -t $(APP_NAME) .
|
||||||
|
|
||||||
run:
|
container-run:
|
||||||
podman run -d --name $(APP_NAME) \
|
podman run -d --name $(APP_NAME) \
|
||||||
-p $(PORT):$(PORT) \
|
-p $(PORT):$(PORT) \
|
||||||
-p $(PORT):$(PORT)/udp \
|
-p $(PORT):$(PORT)/udp \
|
||||||
-v ./instance:/app/instance:Z \
|
-v ./instance:/app/instance:Z \
|
||||||
$(APP_NAME)
|
$(APP_NAME)
|
||||||
|
|
||||||
stop:
|
container-stop:
|
||||||
podman stop $(APP_NAME) && podman rm $(APP_NAME)
|
podman stop $(APP_NAME) && podman rm $(APP_NAME)
|
||||||
|
|
||||||
logs:
|
container-logs:
|
||||||
podman logs -f $(APP_NAME)
|
podman logs -f $(APP_NAME)
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
|
|||||||
1
migrations/README
Normal file
1
migrations/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Single-database configuration for Flask.
|
||||||
50
migrations/alembic.ini
Normal file
50
migrations/alembic.ini
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# template used to generate migration files
|
||||||
|
# file_template = %%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic,flask_migrate
|
||||||
|
|
||||||
|
[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
|
||||||
|
|
||||||
|
[logger_flask_migrate]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = flask_migrate
|
||||||
|
|
||||||
|
[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
|
||||||
113
migrations/env.py
Normal file
113
migrations/env.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import logging
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
logger = logging.getLogger('alembic.env')
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
try:
|
||||||
|
# this works with Flask-SQLAlchemy<3 and Alchemical
|
||||||
|
return current_app.extensions['migrate'].db.get_engine()
|
||||||
|
except (TypeError, AttributeError):
|
||||||
|
# this works with Flask-SQLAlchemy>=3
|
||||||
|
return current_app.extensions['migrate'].db.engine
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine_url():
|
||||||
|
try:
|
||||||
|
return get_engine().url.render_as_string(hide_password=False).replace(
|
||||||
|
'%', '%%')
|
||||||
|
except AttributeError:
|
||||||
|
return str(get_engine().url).replace('%', '%%')
|
||||||
|
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
# from myapp import mymodel
|
||||||
|
# target_metadata = mymodel.Base.metadata
|
||||||
|
config.set_main_option('sqlalchemy.url', get_engine_url())
|
||||||
|
target_db = current_app.extensions['migrate'].db
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def get_metadata():
|
||||||
|
if hasattr(target_db, 'metadatas'):
|
||||||
|
return target_db.metadatas[None]
|
||||||
|
return target_db.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline():
|
||||||
|
"""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=get_metadata(), literal_binds=True
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online():
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# this callback is used to prevent an auto-migration from being generated
|
||||||
|
# when there are no changes to the schema
|
||||||
|
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
||||||
|
def process_revision_directives(context, revision, directives):
|
||||||
|
if getattr(config.cmd_opts, 'autogenerate', False):
|
||||||
|
script = directives[0]
|
||||||
|
if script.upgrade_ops.is_empty():
|
||||||
|
directives[:] = []
|
||||||
|
logger.info('No changes in schema detected.')
|
||||||
|
|
||||||
|
conf_args = current_app.extensions['migrate'].configure_args
|
||||||
|
if conf_args.get("process_revision_directives") is None:
|
||||||
|
conf_args["process_revision_directives"] = process_revision_directives
|
||||||
|
|
||||||
|
connectable = get_engine()
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=get_metadata(),
|
||||||
|
**conf_args
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
24
migrations/script.py.mako
Normal file
24
migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${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():
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
151
migrations/versions/80ccb7597566_initial_schema.py
Normal file
151
migrations/versions/80ccb7597566_initial_schema.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""initial schema
|
||||||
|
|
||||||
|
Revision ID: 80ccb7597566
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-02-05 20:58:36.484000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '80ccb7597566'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('devices',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('mac', sa.String(length=17), nullable=False),
|
||||||
|
sa.Column('device_type', sa.String(length=8), nullable=False),
|
||||||
|
sa.Column('vendor', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('name', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('first_seen', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('last_seen', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('company_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('tx_power', sa.Integer(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('devices', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_devices_mac'), ['mac'], unique=True)
|
||||||
|
|
||||||
|
op.create_table('sensors',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('hostname', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('ip', sa.String(length=15), nullable=False),
|
||||||
|
sa.Column('last_seen', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('config_json', sa.Text(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('sensors', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_sensors_hostname'), ['hostname'], unique=True)
|
||||||
|
|
||||||
|
op.create_table('alerts',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('sensor_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('alert_type', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('source_mac', sa.String(length=17), nullable=True),
|
||||||
|
sa.Column('target_mac', sa.String(length=17), nullable=True),
|
||||||
|
sa.Column('rssi', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('flood_count', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('flood_window', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('timestamp', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['sensor_id'], ['sensors.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('alerts', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_alerts_alert_type'), ['alert_type'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_alerts_sensor_id'), ['sensor_id'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_alerts_timestamp'), ['timestamp'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('events',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('sensor_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('event_type', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('payload_json', sa.Text(), nullable=True),
|
||||||
|
sa.Column('timestamp', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['sensor_id'], ['sensors.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('events', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_events_event_type'), ['event_type'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_events_sensor_id'), ['sensor_id'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_events_timestamp'), ['timestamp'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('probes',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('device_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('sensor_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('ssid', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('rssi', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('channel', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('timestamp', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['sensor_id'], ['sensors.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('probes', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_probes_device_id'), ['device_id'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_probes_sensor_id'), ['sensor_id'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_probes_ssid'), ['ssid'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_probes_timestamp'), ['timestamp'], unique=False)
|
||||||
|
|
||||||
|
op.create_table('sightings',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('device_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('sensor_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('rssi', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('timestamp', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['sensor_id'], ['sensors.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('sightings', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_sightings_device_id'), ['device_id'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_sightings_sensor_id'), ['sensor_id'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_sightings_timestamp'), ['timestamp'], unique=False)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('sightings', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_sightings_timestamp'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_sightings_sensor_id'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_sightings_device_id'))
|
||||||
|
|
||||||
|
op.drop_table('sightings')
|
||||||
|
with op.batch_alter_table('probes', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_probes_timestamp'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_probes_ssid'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_probes_sensor_id'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_probes_device_id'))
|
||||||
|
|
||||||
|
op.drop_table('probes')
|
||||||
|
with op.batch_alter_table('events', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_events_timestamp'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_events_sensor_id'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_events_event_type'))
|
||||||
|
|
||||||
|
op.drop_table('events')
|
||||||
|
with op.batch_alter_table('alerts', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_alerts_timestamp'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_alerts_sensor_id'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_alerts_alert_type'))
|
||||||
|
|
||||||
|
op.drop_table('alerts')
|
||||||
|
with op.batch_alter_table('sensors', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_sensors_hostname'))
|
||||||
|
|
||||||
|
op.drop_table('sensors')
|
||||||
|
with op.batch_alter_table('devices', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_devices_mac'))
|
||||||
|
|
||||||
|
op.drop_table('devices')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "esp32-web"
|
name = "esp32-web"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
description = "REST API backend for ESP32 sensor fleet"
|
description = "REST API backend for ESP32 sensor fleet"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -23,6 +23,9 @@ dev = [
|
|||||||
requires = ["setuptools>=68"]
|
requires = ["setuptools>=68"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
esp32-web = "esp32_web.cli:cli"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
"""ESP32-Web Flask Application."""
|
"""ESP32-Web Flask Application."""
|
||||||
|
from datetime import datetime, UTC
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .extensions import db, migrate
|
from .extensions import db, migrate
|
||||||
|
|
||||||
|
# Track app start time
|
||||||
|
_start_time = None
|
||||||
|
|
||||||
|
|
||||||
def create_app(config_class=Config):
|
def create_app(config_class=Config):
|
||||||
"""Application factory."""
|
"""Application factory."""
|
||||||
|
global _start_time
|
||||||
|
_start_time = datetime.now(UTC)
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config.from_object(config_class)
|
app.config.from_object(config_class)
|
||||||
|
|
||||||
@@ -18,10 +25,20 @@ def create_app(config_class=Config):
|
|||||||
from .api import bp as api_bp
|
from .api import bp as api_bp
|
||||||
app.register_blueprint(api_bp, url_prefix='/api/v1')
|
app.register_blueprint(api_bp, url_prefix='/api/v1')
|
||||||
|
|
||||||
# Health check
|
# Health check with uptime
|
||||||
@app.route('/health')
|
@app.route('/health')
|
||||||
def health():
|
def health():
|
||||||
return {'status': 'ok'}
|
uptime_seconds = int((datetime.now(UTC) - _start_time).total_seconds())
|
||||||
|
days, remainder = divmod(uptime_seconds, 86400)
|
||||||
|
hours, remainder = divmod(remainder, 3600)
|
||||||
|
minutes, seconds = divmod(remainder, 60)
|
||||||
|
if days > 0:
|
||||||
|
uptime_str = f'{days}d{hours}h{minutes}m'
|
||||||
|
elif hours > 0:
|
||||||
|
uptime_str = f'{hours}h{minutes}m{seconds}s'
|
||||||
|
else:
|
||||||
|
uptime_str = f'{minutes}m{seconds}s'
|
||||||
|
return {'status': 'ok', 'uptime': uptime_str, 'uptime_seconds': uptime_seconds}
|
||||||
|
|
||||||
# Start UDP collector in non-testing mode
|
# Start UDP collector in non-testing mode
|
||||||
if not app.config.get('TESTING'):
|
if not app.config.get('TESTING'):
|
||||||
|
|||||||
139
src/esp32_web/cli.py
Normal file
139
src/esp32_web/cli.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""CLI commands for managing the application."""
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
PIDFILE = Path('/tmp/esp32-web.pid')
|
||||||
|
LOGFILE = Path('/tmp/esp32-web.log')
|
||||||
|
|
||||||
|
|
||||||
|
def get_pid() -> int | None:
|
||||||
|
"""Get PID from pidfile if running."""
|
||||||
|
if not PIDFILE.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
pid = int(PIDFILE.read_text().strip())
|
||||||
|
# Check if process exists
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return pid
|
||||||
|
except (ValueError, ProcessLookupError, PermissionError):
|
||||||
|
PIDFILE.unlink(missing_ok=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_running() -> bool:
|
||||||
|
"""Check if server is running."""
|
||||||
|
return get_pid() is not None
|
||||||
|
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
def cli():
|
||||||
|
"""ESP32-Web server management."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option('--port', default=5500, help='Port to listen on')
|
||||||
|
@click.option('--host', default='0.0.0.0', help='Host to bind to')
|
||||||
|
@click.option('--debug', is_flag=True, help='Enable debug mode')
|
||||||
|
def start(port: int, host: str, debug: bool):
|
||||||
|
"""Start the server."""
|
||||||
|
if is_running():
|
||||||
|
click.echo(f'Server already running (PID {get_pid()})')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
click.echo(f'Starting server on {host}:{port}...')
|
||||||
|
|
||||||
|
pid = os.fork()
|
||||||
|
if pid > 0:
|
||||||
|
# Parent - write pidfile and exit
|
||||||
|
PIDFILE.write_text(str(pid))
|
||||||
|
click.echo(f'Server started (PID {pid})')
|
||||||
|
click.echo(f'Logs: {LOGFILE}')
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Child - become daemon
|
||||||
|
os.setsid()
|
||||||
|
|
||||||
|
# Redirect stdout/stderr to logfile
|
||||||
|
log_fd = os.open(str(LOGFILE), os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
|
||||||
|
os.dup2(log_fd, sys.stdout.fileno())
|
||||||
|
os.dup2(log_fd, sys.stderr.fileno())
|
||||||
|
|
||||||
|
# Import and run app
|
||||||
|
from esp32_web import create_app
|
||||||
|
app = create_app()
|
||||||
|
app.run(host=host, port=port, debug=debug, use_reloader=False)
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
def stop():
|
||||||
|
"""Stop the server."""
|
||||||
|
pid = get_pid()
|
||||||
|
if not pid:
|
||||||
|
click.echo('Server not running')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
click.echo(f'Stopping server (PID {pid})...')
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
PIDFILE.unlink(missing_ok=True)
|
||||||
|
click.echo('Server stopped')
|
||||||
|
except ProcessLookupError:
|
||||||
|
PIDFILE.unlink(missing_ok=True)
|
||||||
|
click.echo('Server was not running')
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option('--port', default=5500, help='Port to listen on')
|
||||||
|
@click.option('--host', default='0.0.0.0', help='Host to bind to')
|
||||||
|
@click.option('--debug', is_flag=True, help='Enable debug mode')
|
||||||
|
@click.pass_context
|
||||||
|
def restart(ctx, port: int, host: str, debug: bool):
|
||||||
|
"""Restart the server."""
|
||||||
|
if is_running():
|
||||||
|
ctx.invoke(stop)
|
||||||
|
import time
|
||||||
|
time.sleep(1)
|
||||||
|
ctx.invoke(start, port=port, host=host, debug=debug)
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
def status():
|
||||||
|
"""Show server status."""
|
||||||
|
pid = get_pid()
|
||||||
|
if pid:
|
||||||
|
click.echo(f'Server running (PID {pid})')
|
||||||
|
click.echo(f'Logs: {LOGFILE}')
|
||||||
|
# Try to get health status
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
with urllib.request.urlopen('http://localhost:5500/health', timeout=2) as resp:
|
||||||
|
click.echo(f'Health: {resp.read().decode()}')
|
||||||
|
except Exception:
|
||||||
|
click.echo('Health: unreachable')
|
||||||
|
else:
|
||||||
|
click.echo('Server not running')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option('-n', '--lines', default=50, help='Number of lines to show')
|
||||||
|
@click.option('-f', '--follow', is_flag=True, help='Follow log output')
|
||||||
|
def logs(lines: int, follow: bool):
|
||||||
|
"""Show server logs."""
|
||||||
|
if not LOGFILE.exists():
|
||||||
|
click.echo('No logs found')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if follow:
|
||||||
|
os.execvp('tail', ['tail', '-f', str(LOGFILE)])
|
||||||
|
else:
|
||||||
|
os.execvp('tail', ['tail', '-n', str(lines), str(LOGFILE)])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
cli()
|
||||||
Reference in New Issue
Block a user