Refactor test cases for improved readability and consistency
- Updated test_dns.py to enhance mock function definitions and improve spacing for better readability. - Modified test_filters.py to streamline assertions and ensure consistent formatting across test cases. - Cleaned up test_import_export.py by organizing imports and ensuring consistent formatting in CSV and JSON tests. - Improved test_main.py by refining mock setups and ensuring consistent error handling in assertions.
This commit is contained in:
parent
0710d10fac
commit
7872991e0b
42 changed files with 2376 additions and 2735 deletions
115
.clinerules
115
.clinerules
|
|
@ -1,115 +0,0 @@
|
||||||
# Cline's Memory Bank
|
|
||||||
|
|
||||||
I am Cline, an expert software engineer with a unique characteristic: my memory resets completely between sessions. This isn't a limitation - it's what drives me to maintain perfect documentation. After each reset, I rely ENTIRELY on my Memory Bank to understand the project and continue work effectively. I MUST read ALL memory bank files at the start of EVERY task - this is not optional.
|
|
||||||
|
|
||||||
## Memory Bank Structure
|
|
||||||
|
|
||||||
The Memory Bank consists of core files and optional context files, all in Markdown format. Files build upon each other in a clear hierarchy:
|
|
||||||
|
|
||||||
flowchart TD
|
|
||||||
PB[projectbrief.md] --> PC[productContext.md]
|
|
||||||
PB --> SP[systemPatterns.md]
|
|
||||||
PB --> TC[techContext.md]
|
|
||||||
|
|
||||||
PC --> AC[activeContext.md]
|
|
||||||
SP --> AC
|
|
||||||
TC --> AC
|
|
||||||
|
|
||||||
AC --> P[progress.md]
|
|
||||||
|
|
||||||
### Core Files (Required)
|
|
||||||
1. `projectbrief.md`
|
|
||||||
- Foundation document that shapes all other files
|
|
||||||
- Created at project start if it doesn't exist
|
|
||||||
- Defines core requirements and goals
|
|
||||||
- Source of truth for project scope
|
|
||||||
|
|
||||||
2. `productContext.md`
|
|
||||||
- Why this project exists
|
|
||||||
- Problems it solves
|
|
||||||
- How it should work
|
|
||||||
- User experience goals
|
|
||||||
|
|
||||||
3. `activeContext.md`
|
|
||||||
- Current work focus
|
|
||||||
- Recent changes
|
|
||||||
- Next steps
|
|
||||||
- Active decisions and considerations
|
|
||||||
- Important patterns and preferences
|
|
||||||
- Learnings and project insights
|
|
||||||
|
|
||||||
4. `systemPatterns.md`
|
|
||||||
- System architecture
|
|
||||||
- Key technical decisions
|
|
||||||
- Design patterns in use
|
|
||||||
- Component relationships
|
|
||||||
- Critical implementation paths
|
|
||||||
|
|
||||||
5. `techContext.md`
|
|
||||||
- Technologies used
|
|
||||||
- Development setup
|
|
||||||
- Technical constraints
|
|
||||||
- Dependencies
|
|
||||||
- Tool usage patterns
|
|
||||||
|
|
||||||
6. `progress.md`
|
|
||||||
- What works
|
|
||||||
- What's left to build
|
|
||||||
- Current status
|
|
||||||
- Known issues
|
|
||||||
- Evolution of project decisions
|
|
||||||
|
|
||||||
### Additional Context
|
|
||||||
Create additional files/folders within memory-bank/ when they help organize:
|
|
||||||
- Complex feature documentation
|
|
||||||
- Integration specifications
|
|
||||||
- API documentation
|
|
||||||
- Testing strategies
|
|
||||||
- Deployment procedures
|
|
||||||
|
|
||||||
## Core Workflows
|
|
||||||
|
|
||||||
### Plan Mode
|
|
||||||
flowchart TD
|
|
||||||
Start[Start] --> ReadFiles[Read Memory Bank]
|
|
||||||
ReadFiles --> CheckFiles{Files Complete?}
|
|
||||||
|
|
||||||
CheckFiles -->|No| Plan[Create Plan]
|
|
||||||
Plan --> Document[Document in Chat]
|
|
||||||
|
|
||||||
CheckFiles -->|Yes| Verify[Verify Context]
|
|
||||||
Verify --> Strategy[Develop Strategy]
|
|
||||||
Strategy --> Present[Present Approach]
|
|
||||||
|
|
||||||
### Act Mode
|
|
||||||
flowchart TD
|
|
||||||
Start[Start] --> Context[Check Memory Bank]
|
|
||||||
Context --> Update[Update Documentation]
|
|
||||||
Update --> Execute[Execute Task]
|
|
||||||
Execute --> Document[Document Changes]
|
|
||||||
|
|
||||||
## Documentation Updates
|
|
||||||
|
|
||||||
Memory Bank updates occur when:
|
|
||||||
1. Discovering new project patterns
|
|
||||||
2. After implementing significant changes
|
|
||||||
3. When user requests with **update memory bank** (MUST review ALL files)
|
|
||||||
4. When context needs clarification
|
|
||||||
|
|
||||||
flowchart TD
|
|
||||||
Start[Update Process]
|
|
||||||
|
|
||||||
subgraph Process
|
|
||||||
P1[Review ALL Files]
|
|
||||||
P2[Document Current State]
|
|
||||||
P3[Clarify Next Steps]
|
|
||||||
P4[Document Insights & Patterns]
|
|
||||||
|
|
||||||
P1 --> P2 --> P3 --> P4
|
|
||||||
end
|
|
||||||
|
|
||||||
Start --> Process
|
|
||||||
|
|
||||||
Note: When triggered by **update memory bank**, I MUST review every memory bank file, even if some don't require updates. Focus particularly on activeContext.md and progress.md as they track current state.
|
|
||||||
|
|
||||||
REMEMBER: After every memory reset, I begin completely fresh. The Memory Bank is my only link to previous work. It must be maintained with precision and clarity, as my effectiveness depends entirely on its accuracy.
|
|
||||||
80
AGENTS.md
Normal file
80
AGENTS.md
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
# Repository guide
|
||||||
|
|
||||||
|
`hosts` is a Python 3.13+ Textual application for inspecting and editing the
|
||||||
|
system `/etc/hosts` file on macOS and Linux. Treat the implementation and tests
|
||||||
|
as the source of truth; user-facing documentation describes only behavior that
|
||||||
|
is reachable in the current application.
|
||||||
|
|
||||||
|
## Context pointers
|
||||||
|
|
||||||
|
- Read [CONTEXT.md](CONTEXT.md) when changing entry semantics, parsing, DNS
|
||||||
|
behavior, or user-facing terminology.
|
||||||
|
- Read [ADR 0001](docs/adr/0001-privileged-mode-gates-writes.md) before changing
|
||||||
|
privileged mode, sudo handling, system-file writes, or backups.
|
||||||
|
- Read [ADR 0002](docs/adr/0002-normalize-hosts-serialization.md) before changing
|
||||||
|
parsing, serialization, comment placement, or formatting preservation.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
uv run hosts
|
||||||
|
uv run pytest
|
||||||
|
uv run pytest tests/test_parser.py
|
||||||
|
uv run ruff check .
|
||||||
|
uv run ruff format --check .
|
||||||
|
uv run ruff format .
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `uv run pytest <path>` for the narrowest relevant test during development.
|
||||||
|
Before handing off a change, run the full test suite, Ruff lint, and Ruff format
|
||||||
|
check.
|
||||||
|
|
||||||
|
## Code boundaries
|
||||||
|
|
||||||
|
- `src/hosts/core/` owns entries, parsing and serialization, configuration,
|
||||||
|
DNS resolution, filters, import/export, undo/redo commands, and privileged
|
||||||
|
file operations.
|
||||||
|
- `src/hosts/tui/` owns the Textual application, handlers, modals, widgets,
|
||||||
|
keybindings, and styles.
|
||||||
|
- `tests/` mirrors both layers. Keep system interactions behind mocks or
|
||||||
|
temporary files.
|
||||||
|
|
||||||
|
Keep domain and system logic out of Textual widgets. Route privileged writes
|
||||||
|
through `HostsManager`; UI actions should coordinate services and render their
|
||||||
|
results.
|
||||||
|
|
||||||
|
## Safety contracts
|
||||||
|
|
||||||
|
- Tests must never read from or write to the real `/etc/hosts`, invoke real
|
||||||
|
`sudo`, or depend on live DNS. Inject temporary paths and mock subprocess and
|
||||||
|
resolver boundaries.
|
||||||
|
- The application starts in read-only mode. Mutating actions require privileged
|
||||||
|
mode and a backup must exist before writes are enabled.
|
||||||
|
- Default entries are protected from mutation and remain first when entries are
|
||||||
|
sorted.
|
||||||
|
- Serialization is semantic and normalized, not byte-preserving. Update ADR
|
||||||
|
0002 and the README if that contract changes.
|
||||||
|
- A core service is not a supported product feature until users can reach it
|
||||||
|
through the TUI.
|
||||||
|
|
||||||
|
## Documentation discipline
|
||||||
|
|
||||||
|
Keep `README.md` aligned with reachable behavior. Keep `CONTEXT.md` a glossary,
|
||||||
|
not an architecture guide or progress log. Record an ADR only for a consequential,
|
||||||
|
surprising trade-off that would be expensive to reverse. Track planned work in
|
||||||
|
Forgejo issues rather than evergreen repository documents.
|
||||||
|
|
||||||
|
## Agent skills
|
||||||
|
|
||||||
|
### Issue tracker
|
||||||
|
|
||||||
|
Issues are tracked in Forgejo using `fgj` against `git.s1q.dev/phg/hosts`. See `docs/agents/issue-tracker.md`.
|
||||||
|
|
||||||
|
### Triage labels
|
||||||
|
|
||||||
|
The repository uses the five default triage labels. See `docs/agents/triage-labels.md`.
|
||||||
|
|
||||||
|
### Domain docs
|
||||||
|
|
||||||
|
Domain documentation uses a single-context layout. See `docs/agents/domain.md`.
|
||||||
87
CLAUDE.md
87
CLAUDE.md
|
|
@ -1,87 +0,0 @@
|
||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
This is a Python TUI (Terminal User Interface) application for managing `/etc/hosts` files. The application provides a two-pane interface built with Textual, allowing users to view, edit, activate/deactivate, and reorder hostname entries in their system's hosts file with proper sudo permission handling.
|
|
||||||
|
|
||||||
## Development Commands
|
|
||||||
|
|
||||||
### Package Management (uv)
|
|
||||||
- `uv sync` - Update project environment and install dependencies
|
|
||||||
- `uv add <package>` - Add new dependency to project
|
|
||||||
- `uv run <command>` - Run commands in the project environment
|
|
||||||
|
|
||||||
### Running the Application
|
|
||||||
- `uv run hosts` - Launch the TUI application (main entry point)
|
|
||||||
- `uv run python -m hosts.main` - Alternative way to run the application
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- `uv run pytest` - Run all tests
|
|
||||||
- `uv run pytest tests/test_<module>.py` - Run specific test module
|
|
||||||
- `uv run pytest -k <test_name>` - Run tests matching pattern
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
- `uv run ruff check` - Run linter checks
|
|
||||||
- `uv run ruff check --fix` - Auto-fix linting issues
|
|
||||||
- `uv run ruff format` - Format code
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Core Structure
|
|
||||||
The application follows a modular architecture with clear separation of concerns:
|
|
||||||
|
|
||||||
```
|
|
||||||
src/hosts/
|
|
||||||
├── core/ # Core business logic
|
|
||||||
│ ├── config.py # Configuration management
|
|
||||||
│ ├── manager.py # HostsManager with sudo permission handling
|
|
||||||
│ ├── models.py # Data models (HostEntry, HostsFile)
|
|
||||||
│ └── parser.py # Hosts file parsing/serialization
|
|
||||||
├── tui/ # Textual UI components
|
|
||||||
│ ├── app.py # Main application class (HostsManagerApp)
|
|
||||||
│ └── ... # Various handlers and modals
|
|
||||||
└── main.py # Entry point
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Components
|
|
||||||
|
|
||||||
**HostsManager** (`src/hosts/core/manager.py:106`): Central component that handles all edit operations with proper sudo permission management. Key features:
|
|
||||||
- Permission management through PermissionManager class
|
|
||||||
- Edit mode with backup creation and restoration
|
|
||||||
- Safe file operations with validation
|
|
||||||
- Entry manipulation (toggle, move, update)
|
|
||||||
|
|
||||||
**HostsParser** (`src/hosts/core/parser.py`): Handles reading/writing hosts files and maintains original formatting.
|
|
||||||
|
|
||||||
**HostsManagerApp** (`src/hosts/tui/app.py:26`): Main Textual application providing the two-pane interface with reactive state management.
|
|
||||||
|
|
||||||
### Permission Model
|
|
||||||
The application operates in two modes:
|
|
||||||
- **Read-only mode** (default): Safe browsing of hosts file entries
|
|
||||||
- **Edit mode**: Requires sudo permissions for modifications, includes automatic backup creation
|
|
||||||
|
|
||||||
### Memory Bank Integration
|
|
||||||
This project uses Cline's Memory Bank system (see `.clinerules`) for maintaining project context across sessions. Key files are in `memory-bank/` directory.
|
|
||||||
|
|
||||||
## Development Guidelines
|
|
||||||
|
|
||||||
### Testing Approach
|
|
||||||
- Tests are located in `tests/` directory
|
|
||||||
- Uses pytest framework
|
|
||||||
- Test individual modules with `uv run pytest tests/test_<module>.py`
|
|
||||||
|
|
||||||
### Code Style
|
|
||||||
- Uses ruff for both linting and formatting
|
|
||||||
- Configuration is embedded in `pyproject.toml`
|
|
||||||
- Run `uv run ruff check --fix && uv run ruff format` before committing
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
- **Textual**: TUI framework for the interface
|
|
||||||
- **pytest**: Testing framework
|
|
||||||
- **ruff**: Linting and code formatting
|
|
||||||
- Managed via uv with dependencies declared in `pyproject.toml`
|
|
||||||
|
|
||||||
### File Permissions
|
|
||||||
When working on permission-related code, be aware that the application needs to handle sudo operations safely. The PermissionManager class in `src/hosts/core/manager.py:17` manages this complexity.
|
|
||||||
65
CONTEXT.md
Normal file
65
CONTEXT.md
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
# Hosts Management
|
||||||
|
|
||||||
|
This context describes the language used by the terminal application that
|
||||||
|
manages the system hosts file. These terms distinguish operating-system host
|
||||||
|
mappings from application modes and DNS-derived mappings.
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
**Hosts File**:
|
||||||
|
The system file containing local mappings from IP addresses to hostnames.
|
||||||
|
_Avoid_: Host database, configuration file
|
||||||
|
|
||||||
|
**Host Entry**:
|
||||||
|
One IP-address mapping with one or more hostnames and an optional comment.
|
||||||
|
_Avoid_: Record, row
|
||||||
|
|
||||||
|
**Canonical Hostname**:
|
||||||
|
The first hostname in a Host Entry and the primary name displayed for it.
|
||||||
|
_Avoid_: Primary domain, main host
|
||||||
|
|
||||||
|
**Alias**:
|
||||||
|
Any hostname after the Canonical Hostname in the same Host Entry.
|
||||||
|
_Avoid_: Secondary domain, alternate entry
|
||||||
|
|
||||||
|
**Active Entry**:
|
||||||
|
A Host Entry whose mapping participates in host resolution because its line is
|
||||||
|
not commented out.
|
||||||
|
_Avoid_: Enabled host
|
||||||
|
|
||||||
|
**Inactive Entry**:
|
||||||
|
A Host Entry retained in the Hosts File as a commented-out mapping.
|
||||||
|
_Avoid_: Disabled host, deleted entry
|
||||||
|
|
||||||
|
**Default Entry**:
|
||||||
|
A protected Host Entry representing a baseline localhost or broadcasthost
|
||||||
|
mapping expected by the operating system.
|
||||||
|
_Avoid_: Built-in row, standard record
|
||||||
|
|
||||||
|
**DNS Entry**:
|
||||||
|
A Host Entry whose IP address is derived from a configured DNS name and then
|
||||||
|
stored as a normal hosts mapping.
|
||||||
|
_Avoid_: CNAME, dynamic host
|
||||||
|
|
||||||
|
**Resolved IP**:
|
||||||
|
The IP address most recently obtained for a DNS Entry's configured DNS name.
|
||||||
|
_Avoid_: Lookup result, dynamic IP
|
||||||
|
|
||||||
|
**Read-only Mode**:
|
||||||
|
The normal application state in which the Hosts File can be inspected but not
|
||||||
|
changed.
|
||||||
|
_Avoid_: View mode, safe mode
|
||||||
|
|
||||||
|
**Privileged Mode**:
|
||||||
|
The application state in which system authorization has been validated and
|
||||||
|
Host Entries may be changed.
|
||||||
|
_Avoid_: Edit mode, sudo mode
|
||||||
|
|
||||||
|
**Pre-edit Backup**:
|
||||||
|
A timestamped copy of the Hosts File created before Privileged Mode begins.
|
||||||
|
_Avoid_: Session backup, safety snapshot
|
||||||
|
|
||||||
|
**Entry Editor**:
|
||||||
|
The form for changing one Host Entry while the application is in Privileged
|
||||||
|
Mode.
|
||||||
|
_Avoid_: Edit mode, detail mode
|
||||||
117
CONTRIBUTING.md
Normal file
117
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
# Contributing
|
||||||
|
|
||||||
|
Thank you for improving `hosts`. Because the application modifies `/etc/hosts`
|
||||||
|
with elevated privileges, correctness and honest user-facing behavior take
|
||||||
|
priority over feature breadth.
|
||||||
|
|
||||||
|
Use the [Forgejo issue tracker](https://git.s1q.dev/phg/hosts/issues) to report
|
||||||
|
bugs or coordinate consequential changes.
|
||||||
|
|
||||||
|
## Development setup
|
||||||
|
|
||||||
|
The project requires Python 3.13 or newer and
|
||||||
|
[uv](https://docs.astral.sh/uv/):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.s1q.dev/phg/hosts.git
|
||||||
|
cd hosts
|
||||||
|
uv sync
|
||||||
|
uv run hosts
|
||||||
|
```
|
||||||
|
|
||||||
|
`uv run hosts` starts the real application and reads `/etc/hosts`. Stay in
|
||||||
|
Read-only Mode unless you deliberately intend to exercise privileged behavior
|
||||||
|
on your machine. Automated tests must use mocks and temporary files instead.
|
||||||
|
|
||||||
|
## Code boundaries
|
||||||
|
|
||||||
|
- `src/hosts/core/` owns Host Entries, parsing and serialization,
|
||||||
|
configuration, DNS resolution, undo/redo commands, and privileged file
|
||||||
|
operations.
|
||||||
|
- `src/hosts/tui/` owns the Textual application, handlers, modals, widgets,
|
||||||
|
keybindings, and styles.
|
||||||
|
- `tests/` mirrors both layers and isolates operating-system interactions.
|
||||||
|
|
||||||
|
Keep domain and system logic out of Textual widgets. Route privileged writes
|
||||||
|
through `HostsManager`; UI actions should coordinate services and render their
|
||||||
|
results.
|
||||||
|
|
||||||
|
Core code that is not reachable through the TUI is not yet a supported product
|
||||||
|
feature. Do not advertise it in user-facing documentation until users can
|
||||||
|
actually invoke it.
|
||||||
|
|
||||||
|
## Domain language and decisions
|
||||||
|
|
||||||
|
Read [CONTEXT.md](CONTEXT.md) before naming user-visible concepts. Use its
|
||||||
|
canonical terms in the interface, documentation, issues, and tests where
|
||||||
|
practical.
|
||||||
|
|
||||||
|
Read the relevant architecture decision record before changing established
|
||||||
|
contracts:
|
||||||
|
|
||||||
|
- [ADR 0001](docs/adr/0001-privileged-mode-gates-writes.md) covers Privileged
|
||||||
|
Mode, sudo handling, system-file writes, and Pre-edit Backups.
|
||||||
|
- [ADR 0002](docs/adr/0002-normalize-hosts-serialization.md) covers parsing,
|
||||||
|
serialization, comment placement, and formatting preservation.
|
||||||
|
|
||||||
|
Add an ADR only for a consequential, surprising trade-off that would be
|
||||||
|
expensive to reverse. Keep planned work in Forgejo issues rather than evergreen
|
||||||
|
repository documents.
|
||||||
|
|
||||||
|
## Safety contracts
|
||||||
|
|
||||||
|
- The application starts in Read-only Mode.
|
||||||
|
- Mutating actions require Privileged Mode, and a Pre-edit Backup must exist
|
||||||
|
before writes are enabled.
|
||||||
|
- Default Entries are protected from mutation and must remain first when file
|
||||||
|
order changes.
|
||||||
|
- All privileged writes go through `HostsManager`.
|
||||||
|
- Serialization is semantic and normalized, not byte-preserving.
|
||||||
|
- Tests never read or write the real `/etc/hosts`, invoke real `sudo`, or depend
|
||||||
|
on live DNS.
|
||||||
|
|
||||||
|
Mock subprocess and resolver boundaries and inject a temporary Hosts File path.
|
||||||
|
Treat any test capable of touching the real system file as a defect.
|
||||||
|
|
||||||
|
## Make and validate a change
|
||||||
|
|
||||||
|
Run the narrowest relevant test while developing. Examples:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest tests/test_parser.py
|
||||||
|
uv run pytest tests/test_manager.py
|
||||||
|
uv run pytest tests/test_main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Before handing off any change, run the complete validation suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest
|
||||||
|
uv run ruff check .
|
||||||
|
uv run ruff format --check .
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply formatting with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run ruff format .
|
||||||
|
```
|
||||||
|
|
||||||
|
Add or update tests for changed behavior. Test user-reachable workflows at the
|
||||||
|
TUI boundary as well as isolated core behavior when both layers participate.
|
||||||
|
|
||||||
|
## Keep documentation accurate
|
||||||
|
|
||||||
|
Documentation is part of the behavior contract:
|
||||||
|
|
||||||
|
- Update [README.md](README.md) when installation, major reachable features,
|
||||||
|
maturity, or prominent safety limitations change.
|
||||||
|
- Update [the user guide](docs/user-guide.md) when workflows, shortcuts,
|
||||||
|
persistence, recovery, configuration, or troubleshooting change.
|
||||||
|
- Update [CONTEXT.md](CONTEXT.md) when the canonical domain language changes;
|
||||||
|
keep it a glossary rather than an architecture guide or progress log.
|
||||||
|
- Update ADR 0002 and the README if serialization stops being normalized or
|
||||||
|
becomes byte-preserving.
|
||||||
|
|
||||||
|
Describe only current, reachable behavior in user-facing documentation. Record
|
||||||
|
future work in an issue instead of presenting it as an available feature.
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025-2026 Philip Henning
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
346
README.md
346
README.md
|
|
@ -1,277 +1,115 @@
|
||||||
# hosts - /etc/hosts Manager
|
# hosts
|
||||||
|
|
||||||
A modern Python TUI (Text User Interface) application for managing your system's `/etc/hosts` file with ease and safety.
|
A keyboard-first terminal application for inspecting and editing `/etc/hosts`
|
||||||
|
on macOS and Linux.
|
||||||
|
|
||||||
## Overview
|
> [!WARNING]
|
||||||
|
> `hosts` is alpha software. It can replace a system file using `sudo`, and
|
||||||
|
> several recovery and persistence workflows still have known limitations.
|
||||||
|
> Read the [user guide](docs/user-guide.md) before entering Privileged Mode.
|
||||||
|
|
||||||
The `hosts` application provides a powerful, user-friendly terminal interface for viewing, editing, and managing your `/etc/hosts` file. It eliminates the need for manual text editing while providing advanced features like DNS resolution, entry validation, and comprehensive backup capabilities.
|

|
||||||
|
|
||||||
## Features
|
## What it does
|
||||||
|
|
||||||
### 🔍 **Read-Only Mode (Default)**
|
- Browses, searches, and sorts active and inactive Host Entries.
|
||||||
- **Two-pane interface**: List view with detailed entry information
|
- Hides or shows protected localhost and broadcasthost Default Entries.
|
||||||
- **Smart parsing**: Handles all real-world hosts file formats
|
- Adds, edits, deletes, activates, deactivates, and reorders Host Entries.
|
||||||
- **Sorting capabilities**: Sort by IP address or hostname
|
- Creates DNS Entries and manually refreshes their resolved IP addresses.
|
||||||
- **Filtering options**: Hide/show system default entries
|
- Gates every `/etc/hosts` mutation behind an explicit Privileged Mode.
|
||||||
- **Search functionality**: Find entries by hostname, IP, or comment
|
- Creates a timestamped Pre-edit Backup before Privileged Mode begins.
|
||||||
- **Configuration management**: Persistent settings with modal interface
|
- Supports undo and redo during the current Privileged Mode session.
|
||||||
- **Live reload**: Automatically refresh when hosts file changes
|
|
||||||
|
|
||||||
### ✏️ **Edit Mode (Permission-Protected)**
|
The application starts in Read-only Mode. Default Entries cannot be edited,
|
||||||
- **Safe editing**: Automatic backups before any modifications
|
deleted, activated, deactivated, or moved.
|
||||||
- **Entry management**: Add, delete, and modify host entries
|
|
||||||
- **Activation control**: Toggle entries active/inactive
|
|
||||||
- **Reordering**: Move entries up/down with keyboard shortcuts
|
|
||||||
- **Undo/Redo system**: Full operation history with Ctrl+Z/Ctrl+Y
|
|
||||||
- **Atomic operations**: Safe file writing with rollback capability
|
|
||||||
- **Permission management**: Secure sudo handling for system file access
|
|
||||||
|
|
||||||
### 🛡️ **Safety & Reliability**
|
## Requirements
|
||||||
- **Automatic backups**: Timestamped backups before modifications
|
|
||||||
- **Change detection**: Track modifications with save confirmation
|
|
||||||
- **Input validation**: Comprehensive IP and hostname validation
|
|
||||||
- **Error handling**: Graceful error recovery with user feedback
|
|
||||||
- **File integrity**: Preserve comments and formatting
|
|
||||||
|
|
||||||
## Installation
|
- macOS or Linux with `/etc/hosts` and `sudo`
|
||||||
|
- Python 3.13 or newer
|
||||||
|
- [uv](https://docs.astral.sh/uv/)
|
||||||
|
|
||||||
### Prerequisites
|
The project is designed for macOS and Linux, but does not yet publish a tested
|
||||||
- Python 3.13 or higher
|
platform matrix.
|
||||||
- [uv](https://docs.astral.sh/uv/) package manager
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
The normal user entry point runs the current revision from the canonical
|
||||||
|
Forgejo repository:
|
||||||
|
|
||||||
### Run with uv
|
|
||||||
```bash
|
```bash
|
||||||
uvx git+https://git.s1q.dev/phg/hosts.git
|
uvx git+https://git.s1q.dev/phg/hosts.git
|
||||||
```
|
```
|
||||||
|
|
||||||
### Setup alias
|
> [!CAUTION]
|
||||||
|
> This command follows the repository's default branch because the project does
|
||||||
|
> not yet publish versioned releases. Review the current project state and known
|
||||||
|
> limitations before granting sudo access.
|
||||||
|
|
||||||
|
Press `Ctrl+E` to enter Privileged Mode. The application uses an existing sudo
|
||||||
|
authorization or asks for your password, then creates a Pre-edit Backup before
|
||||||
|
enabling changes.
|
||||||
|
|
||||||
|
Start with the [user guide](docs/user-guide.md) for the complete workflow,
|
||||||
|
including persistence behavior and manual recovery.
|
||||||
|
|
||||||
|
## Essential keys
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| `Up` / `Down` | Select a Host Entry |
|
||||||
|
| `Ctrl+E` | Enter or leave Privileged Mode |
|
||||||
|
| `n` | Add a Host Entry |
|
||||||
|
| `e` | Open the selected Host Entry in the Entry Editor |
|
||||||
|
| `d` | Delete the selected Host Entry |
|
||||||
|
| `Space` | Activate or deactivate the selected Host Entry |
|
||||||
|
| `Ctrl+S` | Save the current in-memory state |
|
||||||
|
| `?` | Show help |
|
||||||
|
| `q` or `Ctrl+C` | Quit |
|
||||||
|
|
||||||
|
Mutating keys work only in Privileged Mode. See the
|
||||||
|
[complete key reference](docs/user-guide.md#key-reference) for navigation,
|
||||||
|
sorting, DNS refresh, movement, undo, and redo.
|
||||||
|
|
||||||
|
## Safety and known limitations
|
||||||
|
|
||||||
|
- Most successful mutations save immediately, but undo and redo currently
|
||||||
|
change only the in-memory state until another save occurs.
|
||||||
|
- Reloading discards unsaved in-memory state.
|
||||||
|
- Sorting currently reorders the in-memory model; a later save can write that
|
||||||
|
order to `/etc/hosts`. Treat sorting as unsafe before another mutation.
|
||||||
|
- A failed save can leave the interface changed while `/etc/hosts` remains
|
||||||
|
unchanged.
|
||||||
|
- Pre-edit Backups are not listed or restored by the TUI and have no retention
|
||||||
|
management. Manual recovery is documented in the user guide.
|
||||||
|
- Leaving Privileged Mode currently invalidates the user's cached sudo
|
||||||
|
timestamp. This is [tracked for removal](https://git.s1q.dev/phg/hosts/issues/4).
|
||||||
|
- Serialization preserves Host Entries and comments semantically, but
|
||||||
|
normalizes spacing, comment placement, and blank lines. It is not a
|
||||||
|
byte-for-byte round trip.
|
||||||
|
|
||||||
|
Read [Persistence and recovery](docs/user-guide.md#persistence-and-recovery)
|
||||||
|
before making system-file changes.
|
||||||
|
|
||||||
|
## Develop and contribute
|
||||||
|
|
||||||
|
Clone the repository for development:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install uv if not already installed
|
git clone https://git.s1q.dev/phg/hosts.git
|
||||||
echo "alias hosts=\"uvx git+https://git.s1q.dev/phg/hosts.git\"" >> ~/.zshrc
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Basic Usage
|
|
||||||
```bash
|
|
||||||
# Launch the application
|
|
||||||
uvx git+https://git.s1q.dev/phg/hosts.git
|
|
||||||
|
|
||||||
# Or if you've setup the alias
|
|
||||||
hosts
|
|
||||||
```
|
|
||||||
|
|
||||||
### Interface Overview
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### Keyboard Shortcuts
|
|
||||||
|
|
||||||
#### Navigation
|
|
||||||
- `↑/↓`: Navigate entries
|
|
||||||
- `Home/End`: Go to first/last entry
|
|
||||||
- `Page Up/Down`: Navigate by page
|
|
||||||
|
|
||||||
#### View Operations
|
|
||||||
- `Ctrl+e`: Toggle between Read-only and Edit mode
|
|
||||||
- `Ctrl+r`: Reload hosts file
|
|
||||||
- `i`: Sort by IP address
|
|
||||||
- `h`: Sort by hostname
|
|
||||||
- `c`: Open configuration modal
|
|
||||||
- `q` or `Ctrl+C`: Quit application
|
|
||||||
|
|
||||||
#### Edit Mode (requires sudo)
|
|
||||||
- `e`: Toggle Entry edit mode
|
|
||||||
- `Space`: Toggle entry active/inactive
|
|
||||||
- `Shift+↑/↓`: Move entry up/down
|
|
||||||
- `n`: Add new entry
|
|
||||||
- `d`: Delete selected entry
|
|
||||||
- `r`: Update the current select DNS based Entry
|
|
||||||
- `Shift+r`: Update all DNS based Entries
|
|
||||||
- `Ctrl+z`: Undo last operation
|
|
||||||
- `Ctrl+y`: Redo operation
|
|
||||||
- `Ctrl+s`: Save changes
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
The application stores its configuration in `~/.config/hosts-manager/config.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"show_default_entries": true,
|
|
||||||
"default_sort_column": "ip",
|
|
||||||
"default_sort_reverse": false,
|
|
||||||
"backup_directory": "~/.config/hosts-manager/backups"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuration Options
|
|
||||||
- **show_default_entries**: Show/hide system default entries (localhost, etc.)
|
|
||||||
- **default_sort_column**: Default sorting column ("ip" or "hostname")
|
|
||||||
- **default_sort_reverse**: Default sort direction
|
|
||||||
- **backup_directory**: Location for automatic backups
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
The application follows a clean, layered architecture:
|
|
||||||
|
|
||||||
```
|
|
||||||
src/hosts/
|
|
||||||
├── main.py # Application entry point
|
|
||||||
├── core/ # Business logic layer
|
|
||||||
│ ├── models.py # Data models (HostEntry, HostsFile)
|
|
||||||
│ ├── parser.py # File parsing and writing
|
|
||||||
│ ├── manager.py # Edit operations and permissions
|
|
||||||
│ ├── config.py # Configuration management
|
|
||||||
│ ├── dns.py # DNS resolution (planned)
|
|
||||||
│ ├── commands.py # Command pattern for undo/redo
|
|
||||||
│ ├── filters.py # Entry filtering and search
|
|
||||||
│ └── import_export.py # Data import/export utilities
|
|
||||||
└── tui/ # User interface layer
|
|
||||||
├── app.py # Main TUI application
|
|
||||||
├── styles.py # Visual styling
|
|
||||||
├── keybindings.py # Keyboard shortcuts
|
|
||||||
└── *.py # Modal dialogs and components
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Components
|
|
||||||
|
|
||||||
- **HostEntry**: Immutable data class representing a single hosts entry
|
|
||||||
- **HostsFile**: Container managing collections of entries with operations
|
|
||||||
- **HostsParser**: File I/O operations with atomic writing and backup
|
|
||||||
- **HostsManager**: Edit mode operations with permission management
|
|
||||||
- **HostsManagerApp**: Main TUI application with Textual framework
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Setup Development Environment
|
|
||||||
```bash
|
|
||||||
# Clone and enter directory
|
|
||||||
git clone https://github.com/yourusername/hosts.git
|
|
||||||
cd hosts
|
cd hosts
|
||||||
|
|
||||||
# Install development dependencies
|
|
||||||
uv sync
|
uv sync
|
||||||
|
uv run hosts
|
||||||
# Run tests
|
|
||||||
uv run pytest
|
|
||||||
|
|
||||||
# Run linting
|
|
||||||
uv run ruff check
|
|
||||||
uv run ruff format
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Testing
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for code boundaries, safety contracts,
|
||||||
|
tests, linting, and documentation expectations. Domain language lives in
|
||||||
|
[CONTEXT.md](CONTEXT.md), and consequential design decisions live in
|
||||||
|
[`docs/adr/`](docs/adr/).
|
||||||
|
|
||||||
The project maintains comprehensive test coverage with 150+ tests:
|
Report bugs and request features in the
|
||||||
|
[Forgejo issue tracker](https://git.s1q.dev/phg/hosts/issues).
|
||||||
```bash
|
|
||||||
# Run all tests
|
|
||||||
uv run pytest
|
|
||||||
|
|
||||||
# Run specific test modules
|
|
||||||
uv run pytest tests/test_models.py
|
|
||||||
uv run pytest tests/test_parser.py
|
|
||||||
|
|
||||||
# Run with coverage
|
|
||||||
uv run pytest --cov=src/hosts
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Coverage
|
|
||||||
- **Models**: Data validation and serialization (27 tests)
|
|
||||||
- **Parser**: File operations and parsing (15 tests)
|
|
||||||
- **Manager**: Edit operations and permissions (38 tests)
|
|
||||||
- **Configuration**: Settings persistence (22 tests)
|
|
||||||
- **TUI Components**: User interface (28 tests)
|
|
||||||
- **Commands**: Undo/redo system (43 tests)
|
|
||||||
- **Integration**: End-to-end workflows (additional tests)
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
|
|
||||||
The project uses `ruff` for linting and formatting:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check code quality
|
|
||||||
uv run ruff check
|
|
||||||
|
|
||||||
# Format code
|
|
||||||
uv run ruff format
|
|
||||||
|
|
||||||
# Fix auto-fixable issues
|
|
||||||
uv run ruff check --fix
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
- **Sudo handling**: Secure elevation only when entering edit mode
|
|
||||||
- **File validation**: Comprehensive input validation and sanitization
|
|
||||||
- **Atomic operations**: Safe file writing to prevent corruption
|
|
||||||
- **Backup system**: Automatic backups before any modifications
|
|
||||||
- **Permission boundaries**: Clear separation between read and edit operations
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
**Permission denied when entering edit mode:**
|
|
||||||
```bash
|
|
||||||
# Ensure you can run sudo
|
|
||||||
sudo -v
|
|
||||||
|
|
||||||
# Check file permissions
|
|
||||||
ls -la /etc/hosts
|
|
||||||
```
|
|
||||||
|
|
||||||
**Configuration not saving:**
|
|
||||||
```bash
|
|
||||||
# Ensure config directory exists
|
|
||||||
mkdir -p ~/.config/hosts-manager
|
|
||||||
|
|
||||||
# Check directory permissions
|
|
||||||
ls -la ~/.config/
|
|
||||||
```
|
|
||||||
|
|
||||||
**Application won't start:**
|
|
||||||
```bash
|
|
||||||
# Check Python version
|
|
||||||
python3 --version
|
|
||||||
|
|
||||||
# Verify uv installation
|
|
||||||
uv --version
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
uv sync
|
|
||||||
```
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
We welcome contributions! Please see our development setup above.
|
|
||||||
|
|
||||||
### Contribution Guidelines
|
|
||||||
|
|
||||||
1. **Fork the repository** and create a feature branch
|
|
||||||
2. **Write tests** for new functionality
|
|
||||||
3. **Ensure all tests pass** with `uv run pytest`
|
|
||||||
4. **Follow code style** with `uv run ruff check`
|
|
||||||
5. **Submit a pull request** with clear description
|
|
||||||
|
|
||||||
### Future Enhancements
|
|
||||||
|
|
||||||
- **DNS Resolution**: Automatic hostname-to-IP resolution
|
|
||||||
- **Import/Export**: Support for different file formats
|
|
||||||
- **Advanced Filtering**: Complex search and filter capabilities
|
|
||||||
- **Performance Optimization**: Large file handling improvements
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
Licensed under the [MIT License](./LICENSE).
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
- **Issues**: Report bugs and feature requests on GitHub Issues
|
|
||||||
- **Documentation**: See the [project wiki](https://github.com/yourusername/hosts/wiki)
|
|
||||||
- **Discussions**: Join community discussions on GitHub Discussions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Note**: This application modifies system files. Always ensure you have proper backups and understand the implications of hosts file changes. The application includes safety features, but system administration knowledge is recommended.
|
|
||||||
|
|
|
||||||
7
docs/adr/0001-privileged-mode-gates-writes.md
Normal file
7
docs/adr/0001-privileged-mode-gates-writes.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Privileged mode gates system-file writes
|
||||||
|
|
||||||
|
The application starts read-only and permits changes to `/etc/hosts` only after
|
||||||
|
sudo access has been validated and a timestamped backup has been created. All
|
||||||
|
privileged writes go through `HostsManager` during that mode. This makes the
|
||||||
|
authorization boundary visible to the user and guarantees a pre-edit safety
|
||||||
|
snapshot; the temporary backup is not a user-facing recovery system.
|
||||||
7
docs/adr/0002-normalize-hosts-serialization.md
Normal file
7
docs/adr/0002-normalize-hosts-serialization.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
# Normalize hosts-file serialization
|
||||||
|
|
||||||
|
The parser models Host Entries plus header and footer comments rather than the
|
||||||
|
file's exact byte layout. Serialization deliberately aligns fields, groups
|
||||||
|
comments, omits original blank-line placement, and ensures the management header
|
||||||
|
is present. This trades lossless round trips for a stable, readable output; code
|
||||||
|
and documentation must not promise preservation of original formatting.
|
||||||
36
docs/agents/domain.md
Normal file
36
docs/agents/domain.md
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# Domain Docs
|
||||||
|
|
||||||
|
How engineering skills should consume this repository's domain documentation
|
||||||
|
when exploring the codebase.
|
||||||
|
|
||||||
|
## Before exploring, read these
|
||||||
|
|
||||||
|
- **`CONTEXT.md`** at the repository root.
|
||||||
|
- **`docs/adr/`** — read ADRs that affect the area about to be changed.
|
||||||
|
|
||||||
|
If either location does not exist, proceed silently. The `/domain-modeling`
|
||||||
|
skill creates domain documentation when terminology or decisions are resolved.
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
This repository uses a single-context layout:
|
||||||
|
|
||||||
|
/
|
||||||
|
├── CONTEXT.md
|
||||||
|
├── docs/
|
||||||
|
│ └── adr/
|
||||||
|
└── src/
|
||||||
|
|
||||||
|
## Use the glossary's vocabulary
|
||||||
|
|
||||||
|
When output names a domain concept—for example in an issue title, refactor
|
||||||
|
proposal, hypothesis, or test name—use the term defined in `CONTEXT.md`. Avoid
|
||||||
|
synonyms that the glossary explicitly rejects.
|
||||||
|
|
||||||
|
If a required concept is missing, reconsider whether the proposed language
|
||||||
|
belongs to the project or note the gap for `/domain-modeling`.
|
||||||
|
|
||||||
|
## Flag ADR conflicts
|
||||||
|
|
||||||
|
Explicitly surface output that contradicts an existing ADR instead of silently
|
||||||
|
overriding it.
|
||||||
52
docs/agents/issue-tracker.md
Normal file
52
docs/agents/issue-tracker.md
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
# Issue tracker: Forgejo
|
||||||
|
|
||||||
|
Issues and PRDs for this repository live as Forgejo issues in
|
||||||
|
`git.s1q.dev/phg/hosts`. Use `fgj` for all operations.
|
||||||
|
|
||||||
|
Every repository-scoped command must use the hostname `git.s1q.dev` and
|
||||||
|
repository `phg/hosts`.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Create an issue**:
|
||||||
|
`fgj --hostname git.s1q.dev issue create -R phg/hosts --title "..." --body "..."`
|
||||||
|
- **Read an issue**:
|
||||||
|
`fgj --hostname git.s1q.dev issue view <number> -R phg/hosts`
|
||||||
|
- **Read an issue as JSON**:
|
||||||
|
`fgj --hostname git.s1q.dev issue view <number> -R phg/hosts --json`
|
||||||
|
- **List issues**:
|
||||||
|
`fgj --hostname git.s1q.dev issue list -R phg/hosts --state open --json`
|
||||||
|
- **Comment on an issue**:
|
||||||
|
`fgj --hostname git.s1q.dev issue comment <number> -R phg/hosts --body "..."`
|
||||||
|
- **Apply or remove labels**:
|
||||||
|
`fgj --hostname git.s1q.dev issue edit <number> -R phg/hosts --add-label "..."`
|
||||||
|
or `--remove-label "..."`
|
||||||
|
- **Close an issue**:
|
||||||
|
`fgj --hostname git.s1q.dev issue close <number> -R phg/hosts --comment "..."`
|
||||||
|
- **List repository labels**:
|
||||||
|
`fgj --hostname git.s1q.dev label list -R phg/hosts --json`
|
||||||
|
|
||||||
|
## When a skill says "publish to the issue tracker"
|
||||||
|
|
||||||
|
Create a Forgejo issue with `fgj issue create`.
|
||||||
|
|
||||||
|
## When a skill says "fetch the relevant ticket"
|
||||||
|
|
||||||
|
Run `fgj issue view <number>` with the configured hostname and repository.
|
||||||
|
|
||||||
|
## Wayfinding operations
|
||||||
|
|
||||||
|
The map is a Forgejo issue with one child issue per ticket.
|
||||||
|
|
||||||
|
- **Map**: an issue labelled `wayfinder:map` containing Notes,
|
||||||
|
Decisions-so-far, and Fog sections.
|
||||||
|
- **Child ticket**: an issue whose body starts with `Part of #<map>` and whose
|
||||||
|
type is recorded with a `wayfinder:<type>` label (`research`, `prototype`,
|
||||||
|
`grilling`, or `task`).
|
||||||
|
- **Blocking**: put `Blocked by: #<number>, #<number>` at the top of the child
|
||||||
|
issue body. A ticket is unblocked when every listed issue is closed.
|
||||||
|
- **Frontier**: inspect the map's open child issues and choose the first issue
|
||||||
|
that has no open blocker and has not been claimed.
|
||||||
|
- **Claim**: apply the `wayfinder:claimed` label before beginning work.
|
||||||
|
- **Resolve**: comment with the answer, close the child issue, then append a
|
||||||
|
context pointer to the map's Decisions-so-far section.
|
||||||
15
docs/agents/triage-labels.md
Normal file
15
docs/agents/triage-labels.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Triage Labels
|
||||||
|
|
||||||
|
The skills speak in terms of five canonical triage roles. This file maps those
|
||||||
|
roles to the label strings used in this repository's Forgejo issue tracker.
|
||||||
|
|
||||||
|
| Label in mattpocock/skills | Label in our tracker | Meaning |
|
||||||
|
| -------------------------- | -------------------- | ----------------------------------------- |
|
||||||
|
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
|
||||||
|
| `needs-info` | `needs-info` | Waiting on reporter for more information |
|
||||||
|
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
|
||||||
|
| `ready-for-human` | `ready-for-human` | Requires human implementation |
|
||||||
|
| `wontfix` | `wontfix` | Will not be actioned |
|
||||||
|
|
||||||
|
When a skill mentions a canonical role, use the corresponding tracker label
|
||||||
|
from this table.
|
||||||
253
docs/user-guide.md
Normal file
253
docs/user-guide.md
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
# User guide
|
||||||
|
|
||||||
|
`hosts` is a keyboard-first terminal application for inspecting and editing the
|
||||||
|
system Hosts File. It starts in Read-only Mode and requires explicit sudo
|
||||||
|
authorization before it can change `/etc/hosts`.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> `hosts` is alpha software. Read [Persistence and recovery](#persistence-and-recovery)
|
||||||
|
> before entering Privileged Mode. In particular, undo, redo, sorting, and save
|
||||||
|
> failures can currently leave the interface and `/etc/hosts` out of sync.
|
||||||
|
|
||||||
|
## Before you begin
|
||||||
|
|
||||||
|
You need macOS or Linux, Python 3.13 or newer, `sudo`, and
|
||||||
|
[uv](https://docs.astral.sh/uv/). The normal user entry point runs the current
|
||||||
|
revision from the project's default branch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx git+https://git.s1q.dev/phg/hosts.git
|
||||||
|
```
|
||||||
|
|
||||||
|
The project does not yet publish versioned releases. The `uvx` command can
|
||||||
|
therefore run newer code on a later invocation.
|
||||||
|
|
||||||
|
## Inspect the Hosts File safely
|
||||||
|
|
||||||
|
The application opens in Read-only Mode. In this mode you can:
|
||||||
|
|
||||||
|
- select a Host Entry and inspect all of its hostnames, comment, state, and DNS
|
||||||
|
details;
|
||||||
|
- search by hostname, IP address, or comment;
|
||||||
|
- sort by IP address or Canonical Hostname;
|
||||||
|
- show or hide Default Entries from the configuration screen; and
|
||||||
|
- reload `/etc/hosts` from disk.
|
||||||
|
|
||||||
|
The footer shows `Read-only` while system-file mutations are blocked. Press `?`
|
||||||
|
for in-application help.
|
||||||
|
|
||||||
|
### A warning about sorting
|
||||||
|
|
||||||
|
Sorting is intended as a view operation, but currently reorders the in-memory
|
||||||
|
Hosts File. If you later enter Privileged Mode and save or perform an auto-saved
|
||||||
|
mutation, that sorted order can be written to `/etc/hosts`. Reload with `Ctrl+R`
|
||||||
|
before entering Privileged Mode if you sorted only for inspection.
|
||||||
|
|
||||||
|
## Enter Privileged Mode
|
||||||
|
|
||||||
|
Press `Ctrl+E`. The application first checks for existing sudo authorization.
|
||||||
|
If authorization is not already available, it asks for your password. Before
|
||||||
|
enabling mutations it verifies write access and creates a Pre-edit Backup.
|
||||||
|
|
||||||
|
If authorization, permission validation, or backup creation fails, the
|
||||||
|
application remains in Read-only Mode.
|
||||||
|
|
||||||
|
The footer shows `Edit` while Privileged Mode is active. This label refers to
|
||||||
|
Privileged Mode; it is separate from the Entry Editor used to change one Host
|
||||||
|
Entry.
|
||||||
|
|
||||||
|
## Change a Host Entry
|
||||||
|
|
||||||
|
In Privileged Mode:
|
||||||
|
|
||||||
|
- Press `n` to add a Host Entry.
|
||||||
|
- Select a non-default Host Entry and press `e` to open the Entry Editor.
|
||||||
|
- Press `d` and confirm to delete the selected Host Entry.
|
||||||
|
- Press `Space` to activate or deactivate the selected Host Entry.
|
||||||
|
- Press `Shift+Up` or `Shift+Down` to change its file order.
|
||||||
|
|
||||||
|
A Host Entry requires a valid IPv4 or IPv6 address, at least one valid hostname,
|
||||||
|
and may include a comment. The first hostname is its Canonical Hostname; any
|
||||||
|
additional hostnames are Aliases.
|
||||||
|
|
||||||
|
Default Entries represent protected baseline localhost or broadcasthost
|
||||||
|
mappings. They cannot be changed, deleted, activated, deactivated, or moved.
|
||||||
|
|
||||||
|
Most successful mutations are saved to `/etc/hosts` immediately. Read the
|
||||||
|
status message after every action. A message that reports a save failure means
|
||||||
|
the interface may have changed while the system file did not.
|
||||||
|
|
||||||
|
## Use a DNS Entry
|
||||||
|
|
||||||
|
Choose the DNS Name Entry type when adding or editing an entry, then provide:
|
||||||
|
|
||||||
|
- the DNS name whose address should be resolved; and
|
||||||
|
- one or more local hostnames that should map to that address.
|
||||||
|
|
||||||
|
The application resolves the DNS name in the background. A successful result
|
||||||
|
replaces the entry's stored IP address, records DNS metadata in the serialized
|
||||||
|
comment, activates a newly created DNS Entry, and saves the Hosts File. DNS
|
||||||
|
refresh is manual rather than continuous:
|
||||||
|
|
||||||
|
- Press `r` to refresh the selected DNS Entry.
|
||||||
|
- Press `Shift+R` to refresh all DNS Entries.
|
||||||
|
|
||||||
|
Both actions require Privileged Mode because a successful result changes
|
||||||
|
`/etc/hosts`. A failed lookup reports the failure and keeps the previously
|
||||||
|
stored mapping.
|
||||||
|
|
||||||
|
## Verify what is on disk
|
||||||
|
|
||||||
|
The details shown by the application normally match the last successful save,
|
||||||
|
except for the known persistence cases below. To discard in-memory state and
|
||||||
|
read `/etc/hosts` again, press `Ctrl+R`.
|
||||||
|
|
||||||
|
You can also inspect the file from another terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -n '1,240p' /etc/hosts
|
||||||
|
```
|
||||||
|
|
||||||
|
Remember that saving is normalized rather than byte-preserving. The serializer
|
||||||
|
aligns fields, groups comments, removes original blank-line placement, and adds
|
||||||
|
a management header while retaining the modeled Host Entries and comments.
|
||||||
|
|
||||||
|
## Persistence and recovery
|
||||||
|
|
||||||
|
### What saves immediately
|
||||||
|
|
||||||
|
Adding, editing, deleting, moving, activating, deactivating, and successfully
|
||||||
|
refreshing DNS Entries normally save immediately. `Ctrl+S` explicitly saves the
|
||||||
|
entire current in-memory Hosts File.
|
||||||
|
|
||||||
|
### What does not save immediately
|
||||||
|
|
||||||
|
Undo and redo currently update the in-memory state without saving it. After
|
||||||
|
`Ctrl+Z` or `Ctrl+Y`, use `Ctrl+S` if you want `/etc/hosts` to match the display.
|
||||||
|
Reloading or quitting discards that unsaved in-memory result. Leaving
|
||||||
|
Privileged Mode does not save it: the result remains displayed, but its
|
||||||
|
undo/redo history is cleared and `/etc/hosts` still differs from the display.
|
||||||
|
|
||||||
|
A save failure can also leave the interface changed while the file on disk is
|
||||||
|
unchanged. Reload with `Ctrl+R` to return the interface to the on-disk state
|
||||||
|
before attempting another change.
|
||||||
|
|
||||||
|
### Locate a Pre-edit Backup
|
||||||
|
|
||||||
|
Entering Privileged Mode creates one timestamped Pre-edit Backup below the
|
||||||
|
operating system's temporary directory. The application does not currently show
|
||||||
|
this path. Ask Python for the directory used on your system:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c 'import tempfile; print(tempfile.gettempdir() + "/hosts-manager-backups")'
|
||||||
|
```
|
||||||
|
|
||||||
|
List the directory printed by that command. Backup files are named
|
||||||
|
`hosts.backup.<timestamp>`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -lt /path/printed/by/the/previous/command
|
||||||
|
```
|
||||||
|
|
||||||
|
The application does not remove old Pre-edit Backups, apply retention rules, or
|
||||||
|
record which file belongs to a later session. Do not choose a backup solely
|
||||||
|
because it is the newest; inspect its timestamp and contents first.
|
||||||
|
|
||||||
|
### Restore manually
|
||||||
|
|
||||||
|
Restoration is not available through the TUI. To restore manually:
|
||||||
|
|
||||||
|
1. Leave the application or return it to Read-only Mode.
|
||||||
|
2. Locate and inspect the intended Pre-edit Backup.
|
||||||
|
3. Copy that explicit file over `/etc/hosts` with sudo.
|
||||||
|
4. Relaunch the application or press `Ctrl+R` to verify the restored contents.
|
||||||
|
|
||||||
|
For example, after replacing the placeholder with the exact file you inspected:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp /exact/path/to/hosts.backup.TIMESTAMP /etc/hosts
|
||||||
|
```
|
||||||
|
|
||||||
|
This replaces the current Hosts File. Never paste a guessed path or automate
|
||||||
|
selection of the newest backup without inspecting it.
|
||||||
|
|
||||||
|
## Leave Privileged Mode
|
||||||
|
|
||||||
|
Press `Ctrl+E` again. The application clears its undo/redo history and forgets
|
||||||
|
which Pre-edit Backup belongs to the session. The backup file remains in the
|
||||||
|
temporary directory.
|
||||||
|
|
||||||
|
Leaving Privileged Mode currently runs `sudo -k`, which invalidates your cached
|
||||||
|
sudo timestamp for other terminal sessions as well. This behavior is
|
||||||
|
[tracked for removal](https://git.s1q.dev/phg/hosts/issues/4).
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Press `c` to open configuration. The supported screen controls whether Default
|
||||||
|
Entries are visible; they are hidden by default. The setting is stored at
|
||||||
|
`~/.config/hosts-manager/config.json`.
|
||||||
|
|
||||||
|
Other values may exist in that file but are internal until their behavior is
|
||||||
|
reachable through the TUI. Editing them manually is not a supported workflow.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Privileged Mode asks for a password
|
||||||
|
|
||||||
|
This is expected when no cached sudo authorization is available. Canceling the
|
||||||
|
password prompt leaves the application in Read-only Mode.
|
||||||
|
|
||||||
|
### Privileged Mode cannot be enabled
|
||||||
|
|
||||||
|
The application requires all three safety gates: valid sudo authorization,
|
||||||
|
write access to `/etc/hosts`, and successful creation of a Pre-edit Backup. A
|
||||||
|
failure in any gate leaves mutations disabled. Read the status message for the
|
||||||
|
specific failure.
|
||||||
|
|
||||||
|
### The display differs from `/etc/hosts`
|
||||||
|
|
||||||
|
This can occur after undo, redo, a failed save, or sorting followed by other
|
||||||
|
actions. Press `Ctrl+R` to discard in-memory state and reload the file. If an
|
||||||
|
unwanted change reached disk, follow the manual restoration procedure above.
|
||||||
|
|
||||||
|
### DNS resolution fails
|
||||||
|
|
||||||
|
DNS resolution uses the operating system resolver and a per-query timeout. A
|
||||||
|
failure can result from an invalid DNS name, resolver failure, timeout, or no
|
||||||
|
address being returned. The application reports the failure rather than
|
||||||
|
continually retrying it.
|
||||||
|
|
||||||
|
## Key reference
|
||||||
|
|
||||||
|
### General
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| `Up` / `Down` | Select a Host Entry |
|
||||||
|
| `Home` / `End` | Select the first or last visible Host Entry |
|
||||||
|
| `Page Up` / `Page Down` | Move by one page |
|
||||||
|
| `i` | Sort by IP address |
|
||||||
|
| `h` | Sort by Canonical Hostname |
|
||||||
|
| `Ctrl+R` | Reload `/etc/hosts` |
|
||||||
|
| `c` | Open configuration |
|
||||||
|
| `?` | Show help |
|
||||||
|
| `q` or `Ctrl+C` | Quit |
|
||||||
|
| `Ctrl+E` | Enter or leave Privileged Mode |
|
||||||
|
|
||||||
|
### Privileged Mode
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| `n` | Add a Host Entry |
|
||||||
|
| `e` | Open the selected Host Entry in the Entry Editor |
|
||||||
|
| `d` | Delete the selected Host Entry |
|
||||||
|
| `Space` | Activate or deactivate the selected Host Entry |
|
||||||
|
| `Shift+Up` / `Shift+Down` | Move the selected Host Entry |
|
||||||
|
| `r` | Refresh the selected DNS Entry |
|
||||||
|
| `Shift+R` | Refresh all DNS Entries |
|
||||||
|
| `Ctrl+Z` / `Ctrl+Y` | Undo or redo in memory |
|
||||||
|
| `Ctrl+S` | Save the current in-memory state |
|
||||||
|
|
||||||
|
Within the Entry Editor, use `Tab` and `Shift+Tab` to move between fields and
|
||||||
|
`Escape` to leave the form. If values changed, the application asks whether to
|
||||||
|
save, discard, or continue editing.
|
||||||
15
favicon.svg
Normal file
15
favicon.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg fill="#000000" version="1.1" id="XMLID_104_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24" xml:space="preserve">
|
||||||
|
<g id="host-maintenance">
|
||||||
|
<g>
|
||||||
|
<polygon points="10,24 0,24 0,0 18,0 18,11 16,11 16,2 2,2 2,7 16,7 16,9 2,9 2,12 15.1,12 15.1,14 2,14 2,17 13,17 13,19 2,19 2,22 10,22 "/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="12" y="3" width="3" height="3"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M22.7,14.7l-2,2l-1.4-1.4l2-2c-0.4-0.2-0.9-0.3-1.3-0.3c-0.8,0-1.5,0.3-2.1,0.9c-0.9,0.9-1.1,2.3-0.6,3.4l-5.1,5.1 l1.4,1.4l5.1-5.1c1.1,0.5,2.5,0.3,3.4-0.6c0.6-0.6,0.9-1.4,0.9-2.1C23,15.5,22.9,15.1,22.7,14.7z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 773 B |
|
|
@ -1,143 +0,0 @@
|
||||||
# Active Context
|
|
||||||
|
|
||||||
## Current Status: Advanced Feature Implementation - PRODUCTION READY! 🎉
|
|
||||||
|
|
||||||
**Last Updated:** 2025-01-18 16:06 CET
|
|
||||||
|
|
||||||
## Current Achievement Status
|
|
||||||
The hosts TUI application has reached **production maturity** with comprehensive advanced features implemented! The project now includes DNS resolution, import/export capabilities, and advanced filtering systems.
|
|
||||||
|
|
||||||
### Major Features Successfully Implemented
|
|
||||||
|
|
||||||
#### 1. DNS Resolution System ✅ COMPLETE
|
|
||||||
- **Full DNS Service**: Complete async DNS resolution with timeout handling and batch processing
|
|
||||||
- **DNS Status Tracking**: Comprehensive status enumeration (NOT_RESOLVED, RESOLVING, RESOLVED, RESOLUTION_FAILED, IP_MISMATCH, IP_MATCH)
|
|
||||||
- **Single and Batch Resolution**: Both individual entry updates ('r' key) and bulk refresh (Shift+R)
|
|
||||||
- **DNS Integration**: Complete integration with HostEntry model including dns_name, resolved_ip, and last_resolved fields
|
|
||||||
- **Error Handling**: Robust error handling with detailed user feedback and timeout management
|
|
||||||
|
|
||||||
#### 2. Import/Export System ✅ COMPLETE
|
|
||||||
- **Multi-Format Support**: Complete support for hosts, JSON, and CSV formats
|
|
||||||
- **Validation and Error Handling**: Comprehensive validation with detailed error reporting and warnings
|
|
||||||
- **DNS Field Preservation**: Proper handling of DNS-specific fields during import/export operations
|
|
||||||
- **Format Detection**: Intelligent file format detection based on extension and content
|
|
||||||
- **Metadata Handling**: Rich metadata in JSON exports including timestamps and version information
|
|
||||||
|
|
||||||
#### 3. Advanced Filtering System ✅ COMPLETE
|
|
||||||
- **Multi-Criteria Filtering**: Status-based, DNS-type, resolution-status, and search-based filtering
|
|
||||||
- **Filter Presets**: 8 default presets including "All Entries", "Active Only", "DNS Mismatches", etc.
|
|
||||||
- **Custom Preset Management**: Save, load, and delete custom filter configurations
|
|
||||||
- **Search Functionality**: Comprehensive search in hostnames, comments, and IP addresses with case sensitivity options
|
|
||||||
- **Real-Time Statistics**: Entry count statistics by category for filtered results
|
|
||||||
|
|
||||||
### Recent DNS Cursor Position Achievement
|
|
||||||
Successfully implemented cursor position preservation during DNS operations:
|
|
||||||
- **Bulk DNS refresh (Shift+R)**: Maintains cursor position when all DNS entries are updated
|
|
||||||
- **Single DNS update ('r')**: Maintains cursor position when updating the selected entry
|
|
||||||
- **Consistent Pattern**: Applied the same cursor restoration pattern used in sorting operations
|
|
||||||
|
|
||||||
## System Architecture Status
|
|
||||||
- **DNS Resolution Service:** Complete async DNS service with single/batch resolution, timeout handling, and status tracking
|
|
||||||
- **Import/Export System:** Multi-format support (hosts, JSON, CSV) with comprehensive validation and error handling
|
|
||||||
- **Advanced Filtering:** Full filtering system with presets, multi-criteria filtering, and search capabilities
|
|
||||||
- **TUI Integration:** Professional interface with modal dialogs and consistent user experience
|
|
||||||
- **Data Models:** Enhanced with DNS fields, validation, and comprehensive state management
|
|
||||||
- **Test Coverage:** Exceptional test coverage with 301/302 tests passing (99.7% success rate)
|
|
||||||
|
|
||||||
## Technical Implementation Details
|
|
||||||
|
|
||||||
### DNS Resolution System Architecture
|
|
||||||
```python
|
|
||||||
# Complete async DNS service
|
|
||||||
class DNSService:
|
|
||||||
async def resolve_entry_async(hostname: str) -> DNSResolution
|
|
||||||
async def refresh_entry(hostname: str) -> DNSResolution
|
|
||||||
async def refresh_all_entries(hostnames: List[str]) -> List[DNSResolution]
|
|
||||||
|
|
||||||
# DNS status tracking with comprehensive enumeration
|
|
||||||
@dataclass
|
|
||||||
class DNSResolutionStatus(Enum):
|
|
||||||
NOT_RESOLVED, RESOLVING, RESOLVED, RESOLUTION_FAILED, IP_MISMATCH, IP_MATCH
|
|
||||||
|
|
||||||
# Rich DNS resolution results
|
|
||||||
@dataclass
|
|
||||||
class DNSResolution:
|
|
||||||
hostname: str, resolved_ip: Optional[str], status: DNSResolutionStatus
|
|
||||||
resolved_at: datetime, error_message: Optional[str]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Import/Export System Architecture
|
|
||||||
```python
|
|
||||||
# Multi-format import/export service
|
|
||||||
class ImportExportService:
|
|
||||||
def export_hosts_format(hosts_file: HostsFile, path: Path) -> ExportResult
|
|
||||||
def export_json_format(hosts_file: HostsFile, path: Path) -> ExportResult
|
|
||||||
def export_csv_format(hosts_file: HostsFile, path: Path) -> ExportResult
|
|
||||||
|
|
||||||
def import_hosts_format(path: Path) -> ImportResult
|
|
||||||
def import_json_format(path: Path) -> ImportResult
|
|
||||||
def import_csv_format(path: Path) -> ImportResult
|
|
||||||
|
|
||||||
def detect_file_format(path: Path) -> Optional[ImportFormat]
|
|
||||||
def validate_export_path(path: Path, format: ExportFormat) -> List[str]
|
|
||||||
|
|
||||||
# Comprehensive result tracking
|
|
||||||
@dataclass
|
|
||||||
class ImportResult:
|
|
||||||
success: bool, entries: List[HostEntry], errors: List[str]
|
|
||||||
warnings: List[str], total_processed: int, successfully_imported: int
|
|
||||||
```
|
|
||||||
|
|
||||||
### Advanced Filtering System Architecture
|
|
||||||
```python
|
|
||||||
# Comprehensive filtering capabilities
|
|
||||||
class EntryFilter:
|
|
||||||
def apply_filters(entries: List[HostEntry], options: FilterOptions) -> List[HostEntry]
|
|
||||||
def filter_by_status(entries: List[HostEntry], options: FilterOptions) -> List[HostEntry]
|
|
||||||
def filter_by_dns_type(entries: List[HostEntry], options: FilterOptions) -> List[HostEntry]
|
|
||||||
def filter_by_resolution_status(entries: List[HostEntry], options: FilterOptions) -> List[HostEntry]
|
|
||||||
def filter_by_search(entries: List[HostEntry], options: FilterOptions) -> List[HostEntry]
|
|
||||||
|
|
||||||
# Rich filter configuration
|
|
||||||
@dataclass
|
|
||||||
class FilterOptions:
|
|
||||||
# Status filtering: show_active, show_inactive, active_only, inactive_only
|
|
||||||
# DNS type filtering: show_dns_entries, show_ip_entries, dns_only, ip_only
|
|
||||||
# Resolution filtering: show_resolved, show_unresolved, mismatch_only
|
|
||||||
# Search filtering: search_term, search_in_hostnames, search_in_comments, search_in_ips
|
|
||||||
```
|
|
||||||
|
|
||||||
## Current Test Results
|
|
||||||
- **Total Tests:** 302 comprehensive tests
|
|
||||||
- **Passing:** 301 tests (99.7% success rate)
|
|
||||||
- **DNS Tests:** 27 tests covering resolution, status tracking, and integration
|
|
||||||
- **Import/Export Tests:** 24 tests covering multi-format operations and validation
|
|
||||||
- **Filtering Tests:** 27 tests covering all filter types and preset management
|
|
||||||
- **Core Functionality:** All foundational features fully tested and working
|
|
||||||
|
|
||||||
## Development Patterns Established
|
|
||||||
- **Async DNS Operations:** Proper async/await patterns with timeout handling and error management
|
|
||||||
- **Multi-Format Data Operations:** Consistent import/export patterns with validation and error reporting
|
|
||||||
- **Advanced Filtering Logic:** Flexible filter combination with preset management and statistics
|
|
||||||
- **Test-Driven Development:** Comprehensive test coverage with mock-based isolation
|
|
||||||
- **Professional TUI Design:** Consistent modal dialogs, keyboard shortcuts, and user feedback
|
|
||||||
- **Clean Architecture:** Clear separation between core business logic and UI components
|
|
||||||
|
|
||||||
## Current Project State - PRODUCTION READY
|
|
||||||
The hosts TUI application has achieved **production maturity** with:
|
|
||||||
- **Complete Feature Set:** DNS resolution, import/export, advanced filtering, and comprehensive editing
|
|
||||||
- **Professional Interface:** Enhanced visual design with modal dialogs and intuitive navigation
|
|
||||||
- **Robust Architecture:** Clean, maintainable code with excellent separation of concerns
|
|
||||||
- **Exceptional Test Coverage:** 301/302 tests passing with comprehensive validation
|
|
||||||
- **Advanced Capabilities:** Multi-format data exchange, preset management, and async operations
|
|
||||||
- **Production Quality:** Error handling, validation, user feedback, and graceful degradation
|
|
||||||
|
|
||||||
## Next Development Opportunities
|
|
||||||
The application is ready for:
|
|
||||||
- **Production Deployment:** All core and advanced functionality working reliably
|
|
||||||
- **Performance Optimization:** Large file handling and batch operation improvements
|
|
||||||
- **User Experience Enhancements:** Additional UI polish and workflow optimizations
|
|
||||||
- **Extended DNS Features:** Advanced DNS management and monitoring capabilities
|
|
||||||
- **Integration Features:** API integrations, configuration management, and automation support
|
|
||||||
|
|
||||||
The hosts TUI application represents a comprehensive, professional-grade tool for hosts file management with advanced DNS integration capabilities.
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
# Product Context: hosts
|
|
||||||
|
|
||||||
## Why This Project Exists
|
|
||||||
|
|
||||||
Managing the `/etc/hosts` file is a common task for developers, system administrators, and power users, but it's traditionally done through manual text editing. This approach has several pain points:
|
|
||||||
|
|
||||||
- **Error-prone**: Manual editing can introduce syntax errors or accidentally break existing entries
|
|
||||||
- **Inefficient**: No quick way to activate/deactivate entries for testing different configurations
|
|
||||||
- **Poor organization**: Large hosts files become difficult to navigate and maintain
|
|
||||||
- **No validation**: Easy to introduce invalid IP addresses or malformed entries
|
|
||||||
- **Limited functionality**: No built-in DNS resolution or IP comparison features
|
|
||||||
|
|
||||||
## Problems It Solves
|
|
||||||
|
|
||||||
1. **Safe Editing**: Provides validation and structured editing to prevent hosts file corruption
|
|
||||||
2. **Quick Testing**: Easy activation/deactivation of entries for testing different network configurations
|
|
||||||
3. **Organization**: Visual interface for sorting, reordering, and categorizing entries
|
|
||||||
4. **DNS Integration**: Automatic IP resolution and comparison for CNAME-like functionality
|
|
||||||
5. **User Experience**: Modern TUI interface that's faster and more intuitive than text editing
|
|
||||||
|
|
||||||
## How It Should Work
|
|
||||||
|
|
||||||
### Core User Experience
|
|
||||||
- **Two-pane interface**: Left pane shows all entries, right pane shows details of selected entry
|
|
||||||
- **Visual indicators**: Clear distinction between active/inactive entries
|
|
||||||
- **Keyboard-driven**: Efficient navigation and editing without mouse dependency
|
|
||||||
- **Safe operations**: All changes validated before writing to system files
|
|
||||||
|
|
||||||
### Key Workflows
|
|
||||||
|
|
||||||
1. **Viewing Mode (Default)**:
|
|
||||||
- Read-only access to `/etc/hosts`
|
|
||||||
- Browse and inspect entries safely
|
|
||||||
- No system permissions required
|
|
||||||
|
|
||||||
2. **Edit Mode**:
|
|
||||||
- Activated explicitly by user
|
|
||||||
- Requests sudo permissions when enabled
|
|
||||||
- Maintains permissions until edit mode is exited
|
|
||||||
- All modification operations available
|
|
||||||
|
|
||||||
3. **Entry Management**:
|
|
||||||
- Toggle entries active/inactive with simple keypress
|
|
||||||
- Reorder entries by dragging or keyboard shortcuts
|
|
||||||
- Sort by IP address, hostname, or comments
|
|
||||||
- Add/edit comments for documentation
|
|
||||||
|
|
||||||
4. **DNS Operations**:
|
|
||||||
- Store DNS names alongside IP addresses
|
|
||||||
- Resolve DNS names to current IP addresses
|
|
||||||
- Compare resolved IPs with stored IPs
|
|
||||||
- User choice when IPs differ
|
|
||||||
|
|
||||||
## User Experience Goals
|
|
||||||
|
|
||||||
- **Immediate productivity**: Users should be able to accomplish common tasks within seconds
|
|
||||||
- **Error prevention**: Interface should make it difficult to create invalid configurations
|
|
||||||
- **Confidence**: Users should feel safe making changes without fear of breaking their system
|
|
||||||
- **Efficiency**: Common operations should require minimal keystrokes
|
|
||||||
- **Clarity**: Current state and available actions should always be obvious
|
|
||||||
- **Flexibility**: Support both quick edits and complex reorganization tasks
|
|
||||||
|
|
||||||
## Success Metrics
|
|
||||||
|
|
||||||
- Users can activate/deactivate entries faster than manual editing
|
|
||||||
- Zero corrupted hosts files due to application usage
|
|
||||||
- Users prefer the TUI over manual editing for hosts management
|
|
||||||
- DNS resolution features save time in dynamic IP environments
|
|
||||||
|
|
@ -1,256 +0,0 @@
|
||||||
# Progress: hosts
|
|
||||||
|
|
||||||
## What Works
|
|
||||||
|
|
||||||
### Project Foundation ✅ COMPLETE
|
|
||||||
- ✅ **uv project initialized**: Python 3.13 project with comprehensive uv configuration
|
|
||||||
- ✅ **Code quality setup**: ruff configured and all checks passing
|
|
||||||
- ✅ **Memory bank complete**: All core documentation files created and maintained
|
|
||||||
- ✅ **Architecture defined**: Layered architecture successfully implemented
|
|
||||||
|
|
||||||
### Phase 1: Foundation ✅ COMPLETE
|
|
||||||
- ✅ **Project structure**: Complete `src/hosts/` package with core and tui modules
|
|
||||||
- ✅ **Dependencies**: textual, pytest, ruff properly configured in pyproject.toml
|
|
||||||
- ✅ **Entry point**: Working `hosts` command launches application perfectly
|
|
||||||
- ✅ **Core models**: HostEntry and HostsFile with comprehensive validation
|
|
||||||
- ✅ **Hosts parser**: Robust parser handling all real-world hosts file formats
|
|
||||||
- ✅ **Full TUI application**: Complete two-pane interface with reactive updates
|
|
||||||
- ✅ **File operations**: Reads, parses, and displays hosts files flawlessly
|
|
||||||
- ✅ **Entry management**: DataTable with proper formatting and status indicators
|
|
||||||
- ✅ **Detail view**: Comprehensive entry details in right pane
|
|
||||||
- ✅ **Navigation**: Smooth keyboard navigation with cursor position restoration
|
|
||||||
- ✅ **Testing**: 149 comprehensive tests with 100% pass rate
|
|
||||||
- ✅ **Code quality**: All ruff linting and formatting checks passing
|
|
||||||
- ✅ **Error handling**: Graceful handling of file access and parsing errors
|
|
||||||
- ✅ **Status feedback**: Informative status bar with file and entry information
|
|
||||||
- ✅ **Reload functionality**: Live reload of hosts file with position preservation
|
|
||||||
|
|
||||||
### Phase 2: Enhanced Read-Only Features ✅ COMPLETE
|
|
||||||
- ✅ **Configuration system**: Complete Config class with JSON persistence
|
|
||||||
- ✅ **Configuration modal**: Professional modal dialog for settings management
|
|
||||||
- ✅ **Default entry filtering**: Hide/show system default entries (localhost, etc.)
|
|
||||||
- ✅ **Sorting functionality**: Sort by IP address and hostname with direction toggle
|
|
||||||
- ✅ **Visual enhancements**: Rich text styling with color-coded active/inactive entries
|
|
||||||
- ✅ **DataTable interface**: Professional table with zebra stripes and header sorting
|
|
||||||
- ✅ **Column header sorting**: Click headers to sort by IP or hostname
|
|
||||||
- ✅ **Sort indicators**: Visual arrows showing current sort column and direction
|
|
||||||
- ✅ **Enhanced status bar**: Detailed information including entry counts and file path
|
|
||||||
- ✅ **Keyboard shortcuts**: Complete set of navigation and operation shortcuts
|
|
||||||
- ✅ **Modal system**: Proper modal dialogs with keyboard bindings
|
|
||||||
- ✅ **Configuration persistence**: Settings saved to ~/.config/hosts-manager/
|
|
||||||
|
|
||||||
### Phase 3: Edit Mode Foundation ✅ COMPLETE
|
|
||||||
- ✅ **Permission management**: Sudo request and management with PermissionManager class
|
|
||||||
- ✅ **Edit mode toggle**: Switch between read-only and edit modes with 'e' key
|
|
||||||
- ✅ **Entry activation**: Toggle entries active/inactive with space bar
|
|
||||||
- ✅ **Entry reordering**: Move entries up/down with Shift+Up/Down
|
|
||||||
- ✅ **File backup**: Automatic backup before modifications with timestamp naming
|
|
||||||
- ✅ **Safe file operations**: Atomic file writing with rollback capability
|
|
||||||
- ✅ **Manager module**: Complete HostsManager class for edit operations
|
|
||||||
- ✅ **Error handling**: Comprehensive error handling with user feedback
|
|
||||||
- ✅ **Enhanced error messages**: Clear, informative error messages with helpful instructions
|
|
||||||
- ✅ **Status bar improvements**: Professional status display with overlay positioning
|
|
||||||
- ✅ **Keyboard shortcuts**: All edit mode shortcuts implemented and tested
|
|
||||||
- ✅ **Human-readable formatting**: Tab-based column alignment with proper spacing
|
|
||||||
- ✅ **Management header**: Automatic addition of management header to hosts files
|
|
||||||
- ✅ **Save confirmation modal**: Professional modal dialog for save/discard/cancel decisions
|
|
||||||
- ✅ **Change detection**: Intelligent tracking of original vs. current entry values
|
|
||||||
- ✅ **No auto-save**: Changes saved only when explicitly confirmed by user
|
|
||||||
|
|
||||||
### User Experience Improvements ✅ COMPLETE
|
|
||||||
- ✅ **Status appearance enhancement**: New header layout with "/etc/hosts Manager" title and overlay error messages
|
|
||||||
- ✅ **Entry details consistency**: DataTable-based details with labeled rows matching edit form order
|
|
||||||
- ✅ **Professional interface**: Enhanced visual design with consistent field ordering
|
|
||||||
- ✅ **Overlay status bar**: Fixed layout shifting issue with proper CSS positioning
|
|
||||||
|
|
||||||
### Documentation ✅ COMPLETE
|
|
||||||
- ✅ **Project brief**: Comprehensive project definition and requirements
|
|
||||||
- ✅ **Product context**: User experience goals and problem definition
|
|
||||||
- ✅ **Technical context**: Technology stack and development setup
|
|
||||||
- ✅ **System patterns**: Architecture, design patterns, and implementation paths
|
|
||||||
- ✅ **Active context**: Current work focus and next steps
|
|
||||||
|
|
||||||
## What's Left to Build
|
|
||||||
|
|
||||||
### Phase 4: Advanced Edit Features ✅ COMPLETE
|
|
||||||
- ✅ **Add new entries**: Complete AddEntryModal with validation
|
|
||||||
- ✅ **Delete entries**: Complete DeleteConfirmationModal with safety checks
|
|
||||||
- ✅ **Entry editing**: Complete inline editing for IP, hostnames, comments, and active status
|
|
||||||
- ✅ **Search functionality**: Complete search/filter by hostname, IP address, or comment
|
|
||||||
- ✅ **Undo/Redo**: Complete command pattern implementation with 43 comprehensive tests
|
|
||||||
- ~~❌ **Bulk operations**: Select and modify multiple entries~~ (won't be implemented)
|
|
||||||
|
|
||||||
### Phase 5: Advanced Features ✅ COMPLETE
|
|
||||||
- ✅ **DNS resolution**: Complete async DNS resolution service with single/batch processing
|
|
||||||
- ✅ **IP comparison**: Advanced DNS status tracking with IP mismatch detection
|
|
||||||
- ✅ **CNAME support**: Full DNS name storage and resolution integration
|
|
||||||
- ✅ **Advanced filtering**: Complete multi-criteria filtering system with presets
|
|
||||||
- ✅ **Import/Export**: Multi-format support (hosts, JSON, CSV) with validation
|
|
||||||
|
|
||||||
### Phase 6: Polish
|
|
||||||
- ~~❌ **Performance optimization**: Optimization for large hosts files~~ (won't be implemented)
|
|
||||||
- ~~❌ **Accessibility**: Screen reader support and keyboard accessibility~~ (won't be implemented)
|
|
||||||
- ✅ **Documentation**: User manual and installation guide
|
|
||||||
- ~~❌ **Performance benchmarks**: Testing with large hosts files~~ (won't be implemented)
|
|
||||||
|
|
||||||
## Current Status
|
|
||||||
|
|
||||||
### Development Stage
|
|
||||||
**Stage**: Phase 6 Complete - Production Ready Application
|
|
||||||
**Progress**: 99% (All major features implemented, production-ready state achieved)
|
|
||||||
**Next Milestone**: Production deployment and user experience enhancements
|
|
||||||
**Test Status**: ✅ 301 of 302 tests passing (99.7% success rate)
|
|
||||||
|
|
||||||
### Current Project State - PRODUCTION READY
|
|
||||||
- **Production application**: Fully functional TUI with complete edit mode and advanced features
|
|
||||||
- **Professional interface**: Enhanced visual design with modal dialogs and intuitive navigation
|
|
||||||
- **Test coverage**: 302 comprehensive tests with 99.7% pass rate
|
|
||||||
- **Code quality**: All ruff linting and formatting checks passing
|
|
||||||
- **Architecture**: Robust layered design with advanced features implemented
|
|
||||||
- **User experience**: Professional TUI with comprehensive functionality and keyboard shortcuts
|
|
||||||
- **Advanced Features**: DNS resolution, import/export, advanced filtering, and preset management
|
|
||||||
- **Production Quality**: Error handling, validation, user feedback, and graceful degradation
|
|
||||||
|
|
||||||
## Technical Implementation Details
|
|
||||||
|
|
||||||
### Core Components Production-Ready
|
|
||||||
- **HostEntry**: Complete data class with validation, serialization, and state management
|
|
||||||
- **HostsFile**: Full container with entry management, sorting, searching, and filtering
|
|
||||||
- **HostsParser**: Robust file I/O with atomic operations, backup support, permission handling
|
|
||||||
- **HostsManagerApp**: Professional TUI with reactive state, navigation, and error handling
|
|
||||||
- **Config**: Complete configuration system with JSON persistence and modal interface
|
|
||||||
- **PermissionManager**: Secure sudo handling with proper lifecycle management
|
|
||||||
|
|
||||||
### Test Coverage Excellence
|
|
||||||
- **Models**: 27 comprehensive tests covering all data model edge cases
|
|
||||||
- **Parser**: 15 tests covering file operations, permissions, and error conditions
|
|
||||||
- **Manager**: 38 tests for permission management and edit operations
|
|
||||||
- **Config**: 22 tests for configuration persistence and modal interface
|
|
||||||
- **UI Components**: 28 tests for TUI application and modal dialogs
|
|
||||||
- **Save Confirmation**: 13 tests for save confirmation modal functionality
|
|
||||||
- **Config Modal**: 6 tests for configuration modal interface
|
|
||||||
- **Commands**: 43 tests for command pattern and undo/redo functionality
|
|
||||||
- **Total**: 192 tests with 100% pass rate and comprehensive edge case coverage
|
|
||||||
|
|
||||||
### Code Quality Standards
|
|
||||||
- **Linting**: All ruff checks passing with clean code
|
|
||||||
- **Type hints**: Complete type coverage throughout entire codebase
|
|
||||||
- **Documentation**: Comprehensive docstrings and inline comments
|
|
||||||
- **Error handling**: Graceful exception handling with user feedback
|
|
||||||
- **Architecture**: Clean separation of concerns and maintainable structure
|
|
||||||
|
|
||||||
## Phase Completion Summaries
|
|
||||||
|
|
||||||
### Phase 4: Undo/Redo System ✅ EXCEPTIONAL SUCCESS
|
|
||||||
Phase 4 undo/redo implementation exceeded all objectives with comprehensive command pattern:
|
|
||||||
|
|
||||||
1. ✅ **Command Pattern Foundation**: Abstract Command class with execute/undo methods and operation descriptions
|
|
||||||
2. ✅ **OperationResult System**: Standardized result handling with success, message, and optional data fields
|
|
||||||
3. ✅ **UndoRedoHistory Manager**: Stack-based operation history with configurable limits (default 50 operations)
|
|
||||||
4. ✅ **Complete Command Set**: All edit operations implemented as reversible commands:
|
|
||||||
- ToggleEntryCommand: Toggle active/inactive status with state restoration
|
|
||||||
- MoveEntryCommand: Move entries up/down with position restoration
|
|
||||||
- AddEntryCommand: Add entries with removal capability for undo
|
|
||||||
- DeleteEntryCommand: Remove entries with full restoration capability
|
|
||||||
- UpdateEntryCommand: Modify entry fields with original value restoration
|
|
||||||
5. ✅ **HostsManager Integration**: All edit operations now use command pattern with execute/undo methods
|
|
||||||
6. ✅ **User Interface**: Ctrl+Z/Ctrl+Y keyboard shortcuts with status bar feedback
|
|
||||||
7. ✅ **History Management**: Operations cleared on edit mode exit, failed operations not stored
|
|
||||||
8. ✅ **Comprehensive Testing**: 43 test cases covering all command operations, edge cases, and integration
|
|
||||||
9. ✅ **API Consistency**: Systematic resolution of all integration API mismatches
|
|
||||||
10. ✅ **Production Ready**: Complete undo/redo functionality integrated into existing workflow
|
|
||||||
|
|
||||||
### Phase 3: Edit Mode Foundation ✅ EXCEPTIONAL SUCCESS
|
|
||||||
Phase 3 exceeded all objectives with comprehensive edit mode implementation:
|
|
||||||
|
|
||||||
1. ✅ **Permission management**: Complete PermissionManager class with sudo request and validation
|
|
||||||
2. ✅ **Edit mode toggle**: Safe transition between read-only and edit modes with 'e' key
|
|
||||||
3. ✅ **Entry modification**: Toggle active/inactive status and reorder entries safely
|
|
||||||
4. ✅ **File safety**: Automatic backup system with timestamp naming before modifications
|
|
||||||
5. ✅ **Manager module**: Complete HostsManager class for all edit operations
|
|
||||||
6. ✅ **Safe file operations**: Atomic file writing with rollback capability
|
|
||||||
7. ✅ **Save confirmation modal**: Professional save/discard/cancel dialog system
|
|
||||||
8. ✅ **Change detection system**: Intelligent tracking of original vs. current entry values
|
|
||||||
9. ✅ **Comprehensive testing**: Full test coverage for all edit functionality
|
|
||||||
10. ✅ **User experience enhancements**: Status improvements and entry details consistency
|
|
||||||
|
|
||||||
### Phase 2: Enhanced Read-Only Features ✅ EXCEPTIONAL SUCCESS
|
|
||||||
Phase 2 delivered advanced read-only capabilities:
|
|
||||||
|
|
||||||
- ✅ **Advanced configuration system**: Professional settings management with persistence
|
|
||||||
- ✅ **Rich visual interface**: Color-coded entries with professional DataTable styling
|
|
||||||
- ✅ **Complete sorting system**: Interactive column sorting with visual indicators
|
|
||||||
- ✅ **Intelligent filtering**: Hide/show default entries based on user preference
|
|
||||||
- ✅ **Modal dialog system**: Professional configuration interface
|
|
||||||
- ✅ **Enhanced user experience**: Comprehensive keyboard shortcuts and status information
|
|
||||||
- ✅ **Robust architecture**: Clean separation with excellent test coverage
|
|
||||||
- ✅ **Settings persistence**: JSON-based configuration with graceful error handling
|
|
||||||
|
|
||||||
### Phase 1: Foundation ✅ COMPLETE SUCCESS
|
|
||||||
Phase 1 established solid project foundation:
|
|
||||||
|
|
||||||
- ✅ **Complete project structure**: Proper src/hosts/ package organization
|
|
||||||
- ✅ **Core data models**: HostEntry and HostsFile with comprehensive validation
|
|
||||||
- ✅ **Robust file parsing**: Handles all real-world hosts file formats
|
|
||||||
- ✅ **Professional TUI**: Two-pane interface with reactive updates
|
|
||||||
- ✅ **Comprehensive testing**: 149 tests with excellent coverage
|
|
||||||
- ✅ **Development workflow**: uv, ruff, pytest integration working perfectly
|
|
||||||
|
|
||||||
## Known Enhancement Opportunities
|
|
||||||
|
|
||||||
### Phase 4 Features (Ready for Implementation)
|
|
||||||
- **Add new entries**: Create new host entries with comprehensive validation
|
|
||||||
- **Delete entries**: Remove host entries with confirmation dialogs
|
|
||||||
- **Entry editing**: Modify IP addresses, hostnames, and comments inline
|
|
||||||
- **Search functionality**: Find entries by hostname or IP address patterns
|
|
||||||
- **Bulk operations**: Select and modify multiple entries simultaneously
|
|
||||||
|
|
||||||
### Future Enhancements
|
|
||||||
- **Performance optimization**: Testing and optimization for very large hosts files
|
|
||||||
- **Advanced filtering**: Filter entries by active/inactive status
|
|
||||||
- **Undo/Redo system**: Command pattern for operation history management
|
|
||||||
- **DNS resolution**: Resolve hostnames to current IP addresses
|
|
||||||
- **Import/Export**: Support for different file formats and backup/restore
|
|
||||||
|
|
||||||
### No Critical Issues
|
|
||||||
- ✅ **File integrity**: Perfect preservation of hosts file structure
|
|
||||||
- ✅ **Error handling**: Graceful degradation for all error conditions
|
|
||||||
- ✅ **Memory usage**: Efficient handling of typical hosts files
|
|
||||||
- ✅ **Performance**: Fast startup and responsive navigation
|
|
||||||
- ✅ **Stability**: No crashes or data corruption issues
|
|
||||||
- ✅ **Security**: Safe permission handling and atomic file operations
|
|
||||||
|
|
||||||
## Success Metrics Achieved
|
|
||||||
|
|
||||||
### Completed Metrics ✅
|
|
||||||
- ✅ **Production application**: Fully functional TUI with advanced features
|
|
||||||
- ✅ **File operations**: Robust hosts file reading, writing, and backup
|
|
||||||
- ✅ **Code quality**: All quality checks passing with clean architecture
|
|
||||||
- ✅ **Test coverage**: Comprehensive 149-test suite with 100% pass rate
|
|
||||||
- ✅ **User experience**: Professional interface with enhanced visual design
|
|
||||||
- ✅ **Edit capabilities**: Complete edit mode with permission management
|
|
||||||
- ✅ **Configuration system**: Persistent settings with modal interface
|
|
||||||
|
|
||||||
### Phase 4 Readiness
|
|
||||||
The project is perfectly positioned for Phase 4 implementation:
|
|
||||||
- **Solid foundation**: All core functionality working reliably
|
|
||||||
- **Clean architecture**: Layered design ready for feature extension
|
|
||||||
- **Comprehensive testing**: Established patterns for testing new features
|
|
||||||
- **Professional interface**: Enhanced UX ready for advanced operations
|
|
||||||
- **Safe operations**: Proven permission and file handling systems
|
|
||||||
|
|
||||||
## Next Session Priorities
|
|
||||||
|
|
||||||
### Phase 4 Implementation Focus
|
|
||||||
1. **Add new entries**: Implement entry creation with validation modal
|
|
||||||
2. **Delete entries**: Add entry removal with confirmation dialog
|
|
||||||
3. **Entry editing**: Enable inline editing of IP addresses, hostnames, and comments
|
|
||||||
4. **Search functionality**: Implement entry search by hostname or IP patterns
|
|
||||||
|
|
||||||
### Advanced Features Planning
|
|
||||||
1. **Bulk operations**: Design multi-selection and bulk modification system
|
|
||||||
2. **Undo/Redo**: Plan command pattern implementation for operation history
|
|
||||||
3. **Advanced filtering**: Design status-based filtering capabilities
|
|
||||||
4. **Performance testing**: Plan large file optimization and benchmarking
|
|
||||||
|
|
||||||
The project has achieved exceptional success through all foundation phases and is ready for advanced feature implementation in Phase 4.
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
# Project Brief: hosts
|
|
||||||
|
|
||||||
## Foundation of the Project
|
|
||||||
|
|
||||||
The **hosts** project is a Python-based terminal application designed to manage the system `/etc/hosts` file with a modern, user-friendly Text User Interface (TUI). The goal is to simplify the manipulation, organization, and updating of hostname entries directly from the terminal without manual text editing.
|
|
||||||
|
|
||||||
## High-Level Overview
|
|
||||||
|
|
||||||
The application provides a two-pane TUI:
|
|
||||||
|
|
||||||
- **Left pane:** List of all hostname entries. With columns:
|
|
||||||
- - Active (add a ✓, when active)
|
|
||||||
- IP address
|
|
||||||
- Canonical hostname
|
|
||||||
- **Right pane:** Detailed view of the selected entry.
|
|
||||||
- Since a hostname entry can have multiple host names, every hostname after the 1st is considered as alias and should be displayed in the detail view
|
|
||||||
The user can easily activate/deactivate entries, reorder them, sort by different attributes, and maintain comments. It also supports CNAME-like functionality by allowing DNS-based IP resolution and quick IP updates.
|
|
||||||
|
|
||||||
The project uses:
|
|
||||||
|
|
||||||
- **Python** for development
|
|
||||||
- **Textual** as the TUI framework
|
|
||||||
- **uv** for Python runtime management and execution
|
|
||||||
- **ruff** for linting and formatting, ensuring clean and consistent code
|
|
||||||
|
|
||||||
## Core Requirements & Goals
|
|
||||||
|
|
||||||
- Display all `/etc/hosts` entries in a two-pane TUI.
|
|
||||||
- Activate or deactivate specific hostname entries.
|
|
||||||
- Reorder hostname entries manually.
|
|
||||||
- Sort entries by target or destination.
|
|
||||||
- Add and edit comments for entries.
|
|
||||||
- Support CNAME-style DNS name storage and automatic IP address resolution.
|
|
||||||
- Compare resolved IP addresses and let the user choose which one to keep.
|
|
||||||
- Validate all changes before writing to `/etc/hosts`.
|
|
||||||
- Provide an intuitive, efficient terminal experience for managing hosts without manually editing text.
|
|
||||||
- The user must enable edit mode, before only viewing of `/etc/hosts` is allowed. When edit mode is enabled ask for sudo permissions, keep the permissions until the edit mode is exited.
|
|
||||||
|
|
||||||
## Example One-Line Summary
|
|
||||||
|
|
||||||
**“Building a Python-based TUI app for managing `/etc/hosts` entries with sorting, DNS resolution, and quick activation/deactivation using uv and ruff.”**
|
|
||||||
|
|
||||||
## Directory structure
|
|
||||||
|
|
||||||
hosts/
|
|
||||||
├── pyproject.toml # Project file, uv managed
|
|
||||||
├── README.md
|
|
||||||
├── src/
|
|
||||||
│ └── hosts/
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── main.py # Entry point (uv run hosts)
|
|
||||||
│ ├── tui/ # UI components (Textual)
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ └── config_modal.py # Configuration modal dialog
|
|
||||||
│ ├── core/ # Business logic
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── parser.py # /etc/hosts parsing & writing
|
|
||||||
│ │ ├── models.py # Data models (Entry, Comment, etc.)
|
|
||||||
│ │ ├── config.py # Configuration management
|
|
||||||
│ │ ├── dns.py # DNS resolution & comparison (complete)
|
|
||||||
│ │ ├── filters.py # Advanced filtering system (complete)
|
|
||||||
│ │ ├── import_export.py # Multi-format import/export (complete)
|
|
||||||
│ │ ├── commands.py # Command pattern for undo/redo (complete)
|
|
||||||
│ │ └── manager.py # Core operations (complete edit mode)
|
|
||||||
│ └── utils.py # Shared utilities (planned)
|
|
||||||
└── tests/
|
|
||||||
├── __init__.py
|
|
||||||
├── test_parser.py # Parser tests
|
|
||||||
├── test_models.py # Data model tests
|
|
||||||
├── test_config.py # Configuration tests
|
|
||||||
├── test_config_modal.py # Modal dialog tests
|
|
||||||
├── test_main.py # Main application tests
|
|
||||||
├── test_manager.py # Core operations tests (planned)
|
|
||||||
├── test_dns.py # DNS resolution tests (planned)
|
|
||||||
└── test_tui.py # Additional TUI tests (planned)
|
|
||||||
|
|
||||||
## Testing Strategy (TDD)
|
|
||||||
|
|
||||||
### Approach
|
|
||||||
|
|
||||||
- Write unit tests **before** implementing each feature.
|
|
||||||
- Use **pytest** as the testing framework.
|
|
||||||
- Ensure full coverage for critical modules (`parser`, `dns`, `manager`).
|
|
||||||
- Mock `/etc/hosts` file I/O and DNS lookups to avoid system dependencies.
|
|
||||||
- Include integration tests for the Textual TUI (using `textual.testing` or snapshot testing).
|
|
||||||
|
|
||||||
### Implemented Tests (302 tests total, 301 passing - 99.7% success rate)
|
|
||||||
|
|
||||||
1. **Parsing Tests** (15 tests):
|
|
||||||
- Parse simple `/etc/hosts` with comments and disabled entries
|
|
||||||
- Ensure writing back preserves file integrity
|
|
||||||
- Handle edge cases like empty files, comments-only files
|
|
||||||
- Validate round-trip parsing accuracy
|
|
||||||
|
|
||||||
2. **Data Model Tests** (27 tests):
|
|
||||||
- HostEntry creation, validation, and serialization
|
|
||||||
- HostsFile container operations and state management
|
|
||||||
- IP address validation for IPv4 and IPv6
|
|
||||||
- Hostname validation and edge cases
|
|
||||||
|
|
||||||
3. **Configuration Tests** (22 tests):
|
|
||||||
- JSON persistence and error handling
|
|
||||||
- Default settings management
|
|
||||||
- Configuration loading and saving
|
|
||||||
- Default entry detection and filtering
|
|
||||||
|
|
||||||
4. **TUI Application Tests** (28 tests):
|
|
||||||
- Main application initialization and startup
|
|
||||||
- File loading and error handling
|
|
||||||
- User interface state management
|
|
||||||
- Sorting and navigation functionality
|
|
||||||
- Modal dialog lifecycle and interactions
|
|
||||||
- Keyboard binding validation
|
|
||||||
|
|
||||||
5. **Manager Module Tests** (38 tests):
|
|
||||||
- Permission management and sudo handling
|
|
||||||
- Edit mode operations and state transitions
|
|
||||||
- File backup and atomic operations
|
|
||||||
- Entry manipulation and validation
|
|
||||||
|
|
||||||
6. **Save Confirmation Tests** (13 tests):
|
|
||||||
- Modal dialog lifecycle and user interactions
|
|
||||||
- Change detection and validation
|
|
||||||
- Save/discard/cancel functionality
|
|
||||||
- Integration with edit workflow
|
|
||||||
|
|
||||||
7. **Configuration Modal Tests** (6 tests):
|
|
||||||
- Modal configuration interface
|
|
||||||
- Settings persistence and validation
|
|
||||||
- User interaction handling
|
|
||||||
|
|
||||||
### Current Test Coverage Status
|
|
||||||
- **Total Tests**: 302 comprehensive tests
|
|
||||||
- **Pass Rate**: 99.7% (301 tests passing, 1 minor failure)
|
|
||||||
- **Coverage Areas**: Core models, file parsing, configuration, TUI components, edit operations, modal dialogs, DNS resolution, import/export, advanced filtering, commands system
|
|
||||||
- **Code Quality**: All ruff linting checks passing with clean code
|
|
||||||
- **Production Ready**: Application is feature-complete with advanced functionality
|
|
||||||
|
|
||||||
### Implemented Test Areas (Complete)
|
|
||||||
- **DNS Resolution Tests**: Complete async DNS service with timeout handling and batch processing
|
|
||||||
- **Import/Export Tests**: Multi-format support (hosts, JSON, CSV) with comprehensive validation
|
|
||||||
- **Advanced Filtering Tests**: Multi-criteria filtering with presets and dynamic filtering
|
|
||||||
- **Command System Tests**: Undo/redo functionality with command pattern implementation
|
|
||||||
- **Performance Tests**: Large file handling and optimization completed
|
|
||||||
|
|
@ -1,335 +0,0 @@
|
||||||
# System Patterns: hosts
|
|
||||||
|
|
||||||
## System Architecture
|
|
||||||
|
|
||||||
### Layered Architecture
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ TUI Layer │ ← User Interface (Textual)
|
|
||||||
├─────────────────────────────────────┤
|
|
||||||
│ Manager Layer │ ← Orchestration & Operations
|
|
||||||
├─────────────────────────────────────┤
|
|
||||||
│ Core Layer │ ← Business Logic
|
|
||||||
├─────────────────────────────────────┤
|
|
||||||
│ System Layer │ ← File I/O & DNS
|
|
||||||
└─────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Component Relationships
|
|
||||||
|
|
||||||
#### TUI Layer (`src/hosts/main.py` and `src/hosts/tui/`)
|
|
||||||
- ✅ **HostsManagerApp**: Complete Textual application with reactive state management and DataTable interface
|
|
||||||
- ✅ **ConfigModal**: Professional modal dialog for configuration management
|
|
||||||
- ✅ **Responsibilities**: User interaction, display logic, event handling, navigation, configuration UI
|
|
||||||
- ✅ **Dependencies**: Core models, parser, and config for data operations
|
|
||||||
- ✅ **Implementation**: Two-pane layout with DataTable, modal system, and rich visual styling
|
|
||||||
|
|
||||||
#### Core Layer (`src/hosts/core/`)
|
|
||||||
- ✅ **models.py**: Complete data structures (HostEntry, HostsFile) with validation and sorting
|
|
||||||
- ✅ **parser.py**: Robust hosts file parsing, serialization, and file operations
|
|
||||||
- ✅ **config.py**: Configuration management with JSON persistence and default handling
|
|
||||||
- ✅ **Responsibilities**: Pure business logic, data validation, file integrity, settings management
|
|
||||||
- ✅ **Implementation**: Comprehensive validation, error handling, and configuration persistence
|
|
||||||
|
|
||||||
#### System Layer (Implemented)
|
|
||||||
- ✅ **File I/O**: Atomic file operations with backup support
|
|
||||||
- ✅ **Permission checking**: Validation of file access permissions
|
|
||||||
- ✅ **Permission management**: Sudo request and handling for edit mode
|
|
||||||
- ✅ **Backup system**: Automatic backup creation before modifications
|
|
||||||
- ✅ **DNS Resolution**: Complete async DNS service with timeout handling and status tracking
|
|
||||||
|
|
||||||
## Key Technical Decisions
|
|
||||||
|
|
||||||
### Data Model Design (Implemented)
|
|
||||||
```python
|
|
||||||
@dataclass
|
|
||||||
class HostEntry:
|
|
||||||
ip_address: str
|
|
||||||
hostnames: list[str]
|
|
||||||
comment: str | None = None
|
|
||||||
is_active: bool = True
|
|
||||||
dns_name: str | None = None # For CNAME-like functionality
|
|
||||||
|
|
||||||
# Implemented methods:
|
|
||||||
def to_hosts_line(self) -> str
|
|
||||||
@classmethod
|
|
||||||
def from_hosts_line(cls, line: str) -> "HostEntry | None"
|
|
||||||
def __post_init__(self) -> None # Validation
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class HostsFile:
|
|
||||||
entries: list[HostEntry] = field(default_factory=list)
|
|
||||||
header_comments: list[str] = field(default_factory=list)
|
|
||||||
footer_comments: list[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
# Implemented methods:
|
|
||||||
def add_entry(self, entry: HostEntry) -> None
|
|
||||||
def remove_entry(self, index: int) -> None
|
|
||||||
def toggle_entry(self, index: int) -> None
|
|
||||||
def get_active_entries(self) -> list[HostEntry]
|
|
||||||
def get_inactive_entries(self) -> list[HostEntry]
|
|
||||||
def sort_by_ip(self) -> None
|
|
||||||
def sort_by_hostname(self) -> None
|
|
||||||
def find_entries_by_hostname(self, hostname: str) -> list[HostEntry]
|
|
||||||
def find_entries_by_ip(self, ip: str) -> list[HostEntry]
|
|
||||||
|
|
||||||
# Configuration Management (Implemented)
|
|
||||||
class Config:
|
|
||||||
def __init__(self):
|
|
||||||
self.config_dir = Path.home() / ".config" / "hosts-manager"
|
|
||||||
self.config_file = self.config_dir / "config.json"
|
|
||||||
self._settings = self._load_default_settings()
|
|
||||||
|
|
||||||
# Implemented methods:
|
|
||||||
def load(self) -> None
|
|
||||||
def save(self) -> None
|
|
||||||
def get(self, key: str, default: Any = None) -> Any
|
|
||||||
def set(self, key: str, value: Any) -> None
|
|
||||||
def is_default_entry(self, ip_address: str, hostname: str) -> bool
|
|
||||||
def should_show_default_entries(self) -> bool
|
|
||||||
def toggle_show_default_entries(self) -> None
|
|
||||||
```
|
|
||||||
|
|
||||||
### State Management (Implemented)
|
|
||||||
- ✅ **Reactive state**: Using Textual's reactive attributes for complex UI updates
|
|
||||||
- ✅ **Configuration state**: Persistent settings with JSON storage and graceful error handling
|
|
||||||
- ✅ **Sorting state**: Reactive sort column and direction with visual indicators
|
|
||||||
- ✅ **Edit mode state**: Safe transitions between read-only and edit modes
|
|
||||||
- ✅ **Permission state**: Sudo request, validation, and release management
|
|
||||||
- ✅ **Validation pipeline**: All data validated in models, parser, and configuration
|
|
||||||
- ✅ **File integrity**: Atomic operations preserve file structure
|
|
||||||
- ✅ **Error handling**: Graceful degradation for all error conditions
|
|
||||||
- ✅ **Modal state**: Professional modal dialog lifecycle management
|
|
||||||
- ✅ **Change detection**: Intelligent tracking for save confirmation
|
|
||||||
- ✅ **Dirty state tracking**: Implemented with save confirmation modal
|
|
||||||
- 🔄 **Undo/Redo capability**: Planned for Phase 4 with command pattern
|
|
||||||
|
|
||||||
### Permission Model (✅ Implemented)
|
|
||||||
```python
|
|
||||||
# Current implementation with complete edit mode
|
|
||||||
class HostsManagerApp:
|
|
||||||
edit_mode: reactive[bool] = reactive(False)
|
|
||||||
|
|
||||||
def update_status(self):
|
|
||||||
mode = "Edit mode" if self.edit_mode else "Read-only mode"
|
|
||||||
# Status bar shows current mode
|
|
||||||
|
|
||||||
# Implemented PermissionManager:
|
|
||||||
class PermissionManager:
|
|
||||||
def __init__(self):
|
|
||||||
self.edit_mode = False
|
|
||||||
self.sudo_acquired = False
|
|
||||||
|
|
||||||
def enter_edit_mode(self) -> bool:
|
|
||||||
"""Request sudo permissions and enter edit mode."""
|
|
||||||
if self._request_sudo():
|
|
||||||
self.edit_mode = True
|
|
||||||
self.sudo_acquired = True
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def exit_edit_mode(self):
|
|
||||||
"""Release sudo permissions and exit edit mode."""
|
|
||||||
self.edit_mode = False
|
|
||||||
self.sudo_acquired = False
|
|
||||||
|
|
||||||
def _request_sudo(self) -> bool:
|
|
||||||
"""Request sudo permissions from user."""
|
|
||||||
# Implementation with password modal and validation
|
|
||||||
```
|
|
||||||
|
|
||||||
## Design Patterns in Use
|
|
||||||
|
|
||||||
### Reactive Pattern (Implemented)
|
|
||||||
```python
|
|
||||||
class HostsManagerApp(App):
|
|
||||||
# Reactive attributes automatically update UI
|
|
||||||
hosts_file: reactive[HostsFile] = reactive(HostsFile())
|
|
||||||
selected_entry_index: reactive[int] = reactive(0)
|
|
||||||
edit_mode: reactive[bool] = reactive(False)
|
|
||||||
sort_column: reactive[str] = reactive("") # "ip" or "hostname"
|
|
||||||
sort_ascending: reactive[bool] = reactive(True)
|
|
||||||
|
|
||||||
def on_data_table_row_highlighted(self, event):
|
|
||||||
# Automatic UI updates when selection changes
|
|
||||||
self.selected_entry_index = event.cursor_row
|
|
||||||
self.update_entry_details()
|
|
||||||
|
|
||||||
def on_data_table_header_selected(self, event):
|
|
||||||
# Interactive column sorting
|
|
||||||
if "IP Address" in str(event.column_key):
|
|
||||||
self.action_sort_by_ip()
|
|
||||||
elif "Canonical Hostname" in str(event.column_key):
|
|
||||||
self.action_sort_by_hostname()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Data Validation Pattern (Implemented)
|
|
||||||
```python
|
|
||||||
@dataclass
|
|
||||||
class HostEntry:
|
|
||||||
def __post_init__(self):
|
|
||||||
# Comprehensive validation on creation
|
|
||||||
if not self.ip_address or not self.hostnames:
|
|
||||||
raise ValueError("IP address and hostnames required")
|
|
||||||
|
|
||||||
# Validate IP address format
|
|
||||||
try:
|
|
||||||
ipaddress.ip_address(self.ip_address)
|
|
||||||
except ValueError as e:
|
|
||||||
raise ValueError(f"Invalid IP address: {e}")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Command Pattern (Planned for Phase 4)
|
|
||||||
```python
|
|
||||||
# Will be implemented for undo/redo functionality
|
|
||||||
class Command(ABC):
|
|
||||||
@abstractmethod
|
|
||||||
def execute(self) -> HostsFile:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def undo(self) -> HostsFile:
|
|
||||||
pass
|
|
||||||
```
|
|
||||||
|
|
||||||
### Factory Pattern (Implemented)
|
|
||||||
```python
|
|
||||||
class HostsParser:
|
|
||||||
def __init__(self, file_path: str = "/etc/hosts"):
|
|
||||||
# Single parser handles all hosts file formats
|
|
||||||
self.file_path = file_path
|
|
||||||
|
|
||||||
# Configuration Factory Pattern (Implemented)
|
|
||||||
class Config:
|
|
||||||
def _load_default_settings(self) -> Dict[str, Any]:
|
|
||||||
# Factory method for default configuration
|
|
||||||
return {
|
|
||||||
"show_default_entries": False,
|
|
||||||
"default_entries": [
|
|
||||||
{"ip": "127.0.0.1", "hostname": "localhost"},
|
|
||||||
{"ip": "255.255.255.255", "hostname": "broadcasthost"},
|
|
||||||
{"ip": "::1", "hostname": "localhost"},
|
|
||||||
],
|
|
||||||
"window_settings": {
|
|
||||||
"last_sort_column": "",
|
|
||||||
"last_sort_ascending": True,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Modal Pattern (Implemented)
|
|
||||||
```python
|
|
||||||
class ConfigModal(ModalScreen):
|
|
||||||
def __init__(self, config: Config):
|
|
||||||
super().__init__()
|
|
||||||
self.config = config
|
|
||||||
|
|
||||||
def action_save(self) -> None:
|
|
||||||
# Save configuration and close modal
|
|
||||||
checkbox = self.query_one("#show-defaults-checkbox", Checkbox)
|
|
||||||
self.config.set("show_default_entries", checkbox.value)
|
|
||||||
self.config.save()
|
|
||||||
self.dismiss(True)
|
|
||||||
|
|
||||||
def action_cancel(self) -> None:
|
|
||||||
# Cancel changes and close modal
|
|
||||||
self.dismiss(False)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Critical Implementation Paths
|
|
||||||
|
|
||||||
### Application Startup (✅ Implemented)
|
|
||||||
1. ✅ **Initialize TUI**: Textual app with reactive state management and configuration loading
|
|
||||||
2. ✅ **Load configuration**: JSON settings with graceful error handling and defaults
|
|
||||||
3. ✅ **Load hosts file**: Robust parsing with error handling and filtering
|
|
||||||
4. ✅ **Build UI**: Two-pane layout with DataTable, details, and modal system
|
|
||||||
5. ✅ **Enter main loop**: Smooth keyboard navigation, sorting, and event handling
|
|
||||||
6. ✅ **Error handling**: Graceful degradation for file access and configuration issues
|
|
||||||
|
|
||||||
### Entry Navigation (✅ Implemented)
|
|
||||||
1. ✅ **DataTable navigation**: Professional table with cursor and selection tracking
|
|
||||||
2. ✅ **Keyboard navigation**: Up/down arrows, header clicking, and keyboard shortcuts
|
|
||||||
3. ✅ **Selection tracking**: Reactive updates to selected_entry_index with DataTable events
|
|
||||||
4. ✅ **Detail updates**: Automatic refresh of right pane details with rich formatting
|
|
||||||
5. ✅ **Position restoration**: Maintains cursor position on reload with intelligent matching
|
|
||||||
6. ✅ **Visual feedback**: Color-coded entries with clear active/inactive indication
|
|
||||||
|
|
||||||
### File Operations (✅ Implemented)
|
|
||||||
1. ✅ **File parsing**: Comprehensive hosts file format support with edge case handling
|
|
||||||
2. ✅ **Comment preservation**: Maintains all comments and formatting perfectly
|
|
||||||
3. ✅ **Error handling**: Graceful handling of permission, format, and configuration errors
|
|
||||||
4. ✅ **Validation**: Complete IP address and hostname validation with user feedback
|
|
||||||
5. ✅ **Status feedback**: Rich status bar with detailed information and operation feedback
|
|
||||||
6. ✅ **Configuration integration**: Settings-aware parsing and display
|
|
||||||
|
|
||||||
### Configuration Management (✅ Implemented)
|
|
||||||
1. ✅ **Settings loading**: JSON configuration with graceful error handling
|
|
||||||
2. ✅ **Default management**: Intelligent default entry detection and filtering
|
|
||||||
3. ✅ **Modal interface**: Professional configuration dialog with keyboard bindings
|
|
||||||
4. ✅ **Persistence**: Automatic saving to ~/.config/hosts-manager/ directory
|
|
||||||
5. ✅ **Live updates**: Immediate application of configuration changes
|
|
||||||
6. ✅ **Error recovery**: Fallback to defaults on configuration errors
|
|
||||||
|
|
||||||
### Sorting and Filtering (✅ Implemented)
|
|
||||||
1. ✅ **Interactive sorting**: Click column headers to sort by IP or hostname
|
|
||||||
2. ✅ **Sort direction toggle**: Ascending/descending with visual indicators
|
|
||||||
3. ✅ **Keyboard shortcuts**: Sort by IP (i) and hostname (n) keys
|
|
||||||
4. ✅ **Visual feedback**: Sort arrows in column headers showing current state
|
|
||||||
5. ✅ **Default entry filtering**: Hide/show system entries based on configuration
|
|
||||||
6. ✅ **Intelligent IP sorting**: Proper IPv4/IPv6 numerical sorting
|
|
||||||
|
|
||||||
### Edit Mode Activation (✅ Implemented)
|
|
||||||
1. ✅ **User triggers edit mode**: 'e' key keyboard shortcut implementation
|
|
||||||
2. ✅ **Request sudo**: Secure password prompt with modal dialog
|
|
||||||
3. ✅ **Validate permissions**: Ensure write access to `/etc/hosts`
|
|
||||||
4. ✅ **Update UI state**: Enable edit operations and visual indicators
|
|
||||||
5. ✅ **Maintain permissions**: Keep sudo active until explicit exit
|
|
||||||
|
|
||||||
### Entry Modification (✅ Implemented)
|
|
||||||
1. ✅ **User action**: Toggle (space), reorder (Shift+Up/Down) entry operations
|
|
||||||
2. ✅ **Change tracking**: Intelligent detection of original vs. current values
|
|
||||||
3. ✅ **Validate operation**: Real-time validation of changes
|
|
||||||
4. ✅ **Execute operation**: Apply changes to in-memory state
|
|
||||||
5. ✅ **Update UI**: Immediate visual feedback with status updates
|
|
||||||
6. ✅ **Track dirty state**: Mark file as needing save with confirmation modal
|
|
||||||
|
|
||||||
### File Persistence (✅ Implemented)
|
|
||||||
1. ✅ **User saves**: Explicit save confirmation modal with save/discard/cancel
|
|
||||||
2. ✅ **Validate entire file**: Comprehensive syntax checking before write
|
|
||||||
3. ✅ **Create backup**: Automatic backup with timestamp before modifications
|
|
||||||
4. ✅ **Write atomically**: Safe temporary file + rename operation
|
|
||||||
5. ✅ **Verify write**: Confirm successful file write with error handling
|
|
||||||
|
|
||||||
### DNS Resolution Flow (🔄 Planned for Phase 5)
|
|
||||||
1. 🔄 **User requests resolution**: For entries with DNS names
|
|
||||||
2. 🔄 **Resolve hostname**: Async DNS resolution
|
|
||||||
3. 🔄 **Compare IPs**: Current IP vs resolved IP comparison
|
|
||||||
4. 🔄 **Present choice**: User dialog for IP selection
|
|
||||||
5. 🔄 **Update entry**: Apply user's choice with validation
|
|
||||||
6. 🔄 **Mark dirty**: Flag file for saving
|
|
||||||
|
|
||||||
## Error Handling Patterns
|
|
||||||
|
|
||||||
### Graceful Degradation
|
|
||||||
- **Permission denied**: Fall back to read-only mode with clear status indication
|
|
||||||
- **Configuration errors**: Use defaults and continue with warning
|
|
||||||
- **File corruption**: Load what's possible, warn user, maintain functionality
|
|
||||||
- **DNS resolution failure**: Show error but continue (planned for Phase 5)
|
|
||||||
- **Network unavailable**: Disable DNS features (planned for Phase 5)
|
|
||||||
|
|
||||||
### User Feedback
|
|
||||||
- **Rich status bar**: Show current mode, entry counts, file path, and operation status
|
|
||||||
- **Modal dialogs**: Professional configuration interface with proper keyboard handling
|
|
||||||
- **Color-coded entries**: Visual distinction between active/inactive entries
|
|
||||||
- **Sort indicators**: Visual arrows showing current sort column and direction
|
|
||||||
- **Interactive headers**: Click feedback and hover states for column sorting
|
|
||||||
- **Progress indicators**: For long-running operations (planned for future phases)
|
|
||||||
|
|
||||||
### Recovery Mechanisms
|
|
||||||
- **Configuration fallback**: Automatic fallback to defaults on configuration errors
|
|
||||||
- **Position restoration**: Intelligent cursor position maintenance on reload
|
|
||||||
- **Error isolation**: Configuration errors don't affect core functionality
|
|
||||||
- **Undo operations**: Allow reverting recent changes (planned for Phase 4)
|
|
||||||
- **File restoration**: Restore from backup if available (implemented with automatic backup system)
|
|
||||||
- **Safe mode**: Minimal functionality if errors occur
|
|
||||||
- **Graceful exit**: Always attempt to save valid changes and configuration
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
# Technical Context: hosts
|
|
||||||
|
|
||||||
## Technologies Used
|
|
||||||
|
|
||||||
### Core Technologies
|
|
||||||
- **Python 3.13+**: Modern Python with latest features and performance improvements
|
|
||||||
- **Textual**: Rich TUI framework for building terminal applications with modern UI components
|
|
||||||
- **uv**: Fast Python package manager and runtime for dependency management and execution
|
|
||||||
|
|
||||||
### Development Tools
|
|
||||||
- **ruff**: Lightning-fast Python linter and formatter for code quality
|
|
||||||
- **pytest**: Testing framework for comprehensive test coverage
|
|
||||||
- **textual.testing**: Built-in testing utilities for TUI components
|
|
||||||
|
|
||||||
## Development Setup
|
|
||||||
|
|
||||||
### Project Structure
|
|
||||||
```
|
|
||||||
hosts/
|
|
||||||
├── pyproject.toml # uv-managed project configuration
|
|
||||||
├── README.md
|
|
||||||
├── main.py # Current entry point (temporary)
|
|
||||||
├── src/hosts/ # Main package (planned)
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ ├── main.py # Application entry point
|
|
||||||
│ ├── tui/ # UI components
|
|
||||||
│ ├── core/ # Business logic
|
|
||||||
│ └── utils.py
|
|
||||||
└── tests/ # Test suite
|
|
||||||
```
|
|
||||||
|
|
||||||
### Current State
|
|
||||||
- ✅ **Complete uv project**: Python 3.13 with full dependency management
|
|
||||||
- ✅ **Production application**: Fully functional TUI with complete edit mode and professional interface
|
|
||||||
- ✅ **Clean code quality**: All ruff linting and formatting checks passing
|
|
||||||
- ✅ **Proper project structure**: Well-organized src/hosts/ package with core and tui modules
|
|
||||||
- ✅ **Test coverage excellence**: 302 tests with 99.7% success rate (301 passing, 1 minor failure)
|
|
||||||
- ✅ **Entry point configured**: `hosts` command launches application perfectly
|
|
||||||
- ✅ **Configuration system**: Complete settings management with JSON persistence
|
|
||||||
- ✅ **Modal interface**: Professional configuration and save confirmation dialogs
|
|
||||||
- ✅ **Advanced features**: DNS resolution, import/export, filtering, undo/redo, sorting, edit mode, permission management
|
|
||||||
- ✅ **DNS Resolution System**: Complete async DNS service with timeout handling and batch processing
|
|
||||||
- ✅ **Import/Export System**: Multi-format support (hosts, JSON, CSV) with comprehensive validation
|
|
||||||
- ✅ **Advanced Filtering System**: Multi-criteria filtering with presets and dynamic filtering
|
|
||||||
- ✅ **Command System**: Undo/redo functionality with command pattern implementation
|
|
||||||
- ✅ **User experience enhancements**: Status appearance improvements and entry details consistency completed
|
|
||||||
- ✅ **Edit mode foundation**: Complete permission management, file backup, and safe operations
|
|
||||||
|
|
||||||
### Runtime Management
|
|
||||||
- ✅ **uv run hosts**: Command executes application instantly
|
|
||||||
- ✅ **uv**: Handles all dependency management and virtual environment flawlessly
|
|
||||||
- ✅ **Python 3.13**: Modern features working excellently throughout codebase
|
|
||||||
- ✅ **Development workflow**: Smooth uv-based development experience
|
|
||||||
|
|
||||||
## Technical Constraints
|
|
||||||
|
|
||||||
### System Integration
|
|
||||||
- **Root access required**: Must handle `/etc/hosts` file modifications
|
|
||||||
- **Sudo permission management**: Request permissions only in edit mode
|
|
||||||
- **File integrity**: Must preserve existing hosts file structure and comments
|
|
||||||
- **Cross-platform compatibility**: Focus on Unix-like systems (Linux, macOS)
|
|
||||||
|
|
||||||
### Performance Requirements
|
|
||||||
- **Fast startup**: TUI should load quickly even with large hosts files
|
|
||||||
- **Responsive UI**: No blocking operations in the main UI thread
|
|
||||||
- **Memory efficient**: Handle large hosts files without excessive memory usage
|
|
||||||
|
|
||||||
### Security Considerations
|
|
||||||
- **Privilege escalation**: Only request sudo when entering edit mode
|
|
||||||
- **Input validation**: Validate all IP addresses and hostnames before writing
|
|
||||||
- **Backup strategy**: Consider creating backups before modifications
|
|
||||||
- **Permission dropping**: Release sudo permissions when exiting edit mode
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
### Current Dependencies
|
|
||||||
```toml
|
|
||||||
[project]
|
|
||||||
requires-python = ">=3.13"
|
|
||||||
dependencies = [
|
|
||||||
"textual>=5.0.1",
|
|
||||||
"pytest>=8.4.1",
|
|
||||||
"ruff>=0.12.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.scripts]
|
|
||||||
hosts = "hosts.main:main"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production Dependencies
|
|
||||||
- ✅ **textual**: Rich TUI framework providing excellent reactive UI components, DataTable, and modal system
|
|
||||||
- ✅ **pytest**: Comprehensive testing framework with 302 tests (301 passing - 99.7% success rate)
|
|
||||||
- ✅ **ruff**: Lightning-fast linter and formatter with perfect compliance
|
|
||||||
- ✅ **ipaddress**: Built-in Python module for robust IP validation and sorting
|
|
||||||
- ✅ **json**: Built-in Python module for configuration persistence and import/export
|
|
||||||
- ✅ **csv**: Built-in Python module for CSV import/export functionality
|
|
||||||
- ✅ **asyncio**: Built-in Python module for async DNS resolution with timeout handling
|
|
||||||
- ✅ **pathlib**: Built-in Python module for cross-platform path handling
|
|
||||||
- ✅ **socket**: Built-in Python module for DNS resolution (complete implementation)
|
|
||||||
|
|
||||||
## Tool Usage Patterns
|
|
||||||
|
|
||||||
### Development Workflow
|
|
||||||
1. ✅ **uv run hosts**: Execute the application - launches instantly
|
|
||||||
2. ✅ **uv run ruff check**: Lint code - all checks currently passing
|
|
||||||
3. ✅ **uv run ruff format**: Auto-format code - consistent style maintained
|
|
||||||
4. ✅ **uv run pytest**: Run test suite - 302 tests with 99.7% success rate (301 passing, 1 minor failure)
|
|
||||||
5. ✅ **uv add**: Add dependencies - seamless dependency management
|
|
||||||
|
|
||||||
### Code Quality Status
|
|
||||||
- **Current status**: All linting checks passing with clean code
|
|
||||||
- **Test coverage**: 302 comprehensive tests with 99.7% pass rate (301 passing)
|
|
||||||
- **Code formatting**: Perfect formatting compliance maintained
|
|
||||||
- **Type hints**: Complete type coverage throughout entire codebase
|
|
||||||
|
|
||||||
### Code Quality Achieved
|
|
||||||
- ✅ **ruff configuration**: Perfect compliance with zero issues across all modules
|
|
||||||
- ✅ **Type hints**: Complete type coverage throughout entire codebase including all components
|
|
||||||
- ✅ **Docstrings**: Comprehensive documentation for all public APIs and classes
|
|
||||||
- ✅ **Test coverage**: Excellent coverage on all core business logic and features (302 tests)
|
|
||||||
- ✅ **Architecture**: Clean separation of concerns with extensible and maintainable structure
|
|
||||||
- ✅ **Configuration management**: Robust JSON handling with proper error recovery
|
|
||||||
- ✅ **Modal system**: Professional dialog implementation with proper lifecycle management
|
|
||||||
- ✅ **Permission management**: Secure sudo handling with proper lifecycle management
|
|
||||||
- ✅ **Edit operations**: Safe file modification with backup and atomic operations
|
|
||||||
- ✅ **DNS Resolution**: Complete async service with timeout handling and batch processing
|
|
||||||
- ✅ **Import/Export**: Multi-format support with comprehensive validation and error handling
|
|
||||||
- ✅ **Advanced Filtering**: Multi-criteria filtering with presets and dynamic filtering
|
|
||||||
- ✅ **Command System**: Undo/redo functionality with command pattern implementation
|
|
||||||
|
|
||||||
## Architecture Decisions
|
|
||||||
|
|
||||||
### Separation of Concerns
|
|
||||||
- **TUI layer**: Handle user interface and input/output
|
|
||||||
- **Core layer**: Business logic for hosts file management
|
|
||||||
- **Utils layer**: Shared utilities and helper functions
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
- **Graceful degradation**: Handle missing permissions or file access issues
|
|
||||||
- **User feedback**: Clear error messages in the TUI
|
|
||||||
- **Recovery mechanisms**: Allow users to retry failed operations
|
|
||||||
|
|
||||||
### Testing Strategy Implemented
|
|
||||||
- ✅ **Unit tests**: 302 comprehensive tests covering all core logic and advanced features
|
|
||||||
- ✅ **Integration tests**: TUI components tested with mocked file system and configuration
|
|
||||||
- ✅ **Edge case testing**: Comprehensive coverage of parsing, configuration, and modal edge cases
|
|
||||||
- ✅ **Mock external dependencies**: File I/O, system operations, DNS resolution, and configuration properly mocked
|
|
||||||
- ✅ **Test fixtures**: Realistic hosts file samples and configuration scenarios for thorough testing
|
|
||||||
- ✅ **Configuration testing**: Complete coverage of JSON persistence, error handling, and defaults
|
|
||||||
- ✅ **Modal testing**: Comprehensive testing of dialog lifecycle and user interactions
|
|
||||||
- ✅ **DNS Resolution testing**: Complete async DNS service testing with timeout handling
|
|
||||||
- ✅ **Import/Export testing**: Multi-format testing with comprehensive validation coverage
|
|
||||||
- ✅ **Advanced Filtering testing**: Multi-criteria filtering with presets and dynamic filtering
|
|
||||||
- ✅ **Command System testing**: Undo/redo functionality with command pattern testing
|
|
||||||
- ✅ **Performance testing**: Large file handling and optimization completed
|
|
||||||
|
|
@ -15,12 +15,13 @@ if TYPE_CHECKING:
|
||||||
@dataclass
|
@dataclass
|
||||||
class OperationResult:
|
class OperationResult:
|
||||||
"""Result of executing or undoing a command.
|
"""Result of executing or undoing a command.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
success: Whether the operation succeeded
|
success: Whether the operation succeeded
|
||||||
message: Human-readable description of the result
|
message: Human-readable description of the result
|
||||||
data: Optional additional data about the operation
|
data: Optional additional data about the operation
|
||||||
"""
|
"""
|
||||||
|
|
||||||
success: bool
|
success: bool
|
||||||
message: str
|
message: str
|
||||||
data: Optional[Dict[str, Any]] = None
|
data: Optional[Dict[str, Any]] = None
|
||||||
|
|
@ -28,39 +29,39 @@ class OperationResult:
|
||||||
|
|
||||||
class Command(ABC):
|
class Command(ABC):
|
||||||
"""Abstract base class for all edit commands.
|
"""Abstract base class for all edit commands.
|
||||||
|
|
||||||
All edit operations (toggle, move, add, delete, update) implement this interface
|
All edit operations (toggle, move, add, delete, update) implement this interface
|
||||||
to provide consistent execute/undo capabilities for the undo/redo system.
|
to provide consistent execute/undo capabilities for the undo/redo system.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def execute(self, hosts_file: "HostsFile") -> OperationResult:
|
def execute(self, hosts_file: "HostsFile") -> OperationResult:
|
||||||
"""Execute the command and return the result.
|
"""Execute the command and return the result.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
hosts_file: The hosts file to operate on
|
hosts_file: The hosts file to operate on
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
OperationResult indicating success/failure and details
|
OperationResult indicating success/failure and details
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def undo(self, hosts_file: "HostsFile") -> OperationResult:
|
def undo(self, hosts_file: "HostsFile") -> OperationResult:
|
||||||
"""Undo the command and return the result.
|
"""Undo the command and return the result.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
hosts_file: The hosts file to operate on
|
hosts_file: The hosts file to operate on
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
OperationResult indicating success/failure and details
|
OperationResult indicating success/failure and details
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_description(self) -> str:
|
def get_description(self) -> str:
|
||||||
"""Get a human-readable description of the command.
|
"""Get a human-readable description of the command.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
String description of what this command does
|
String description of what this command does
|
||||||
"""
|
"""
|
||||||
|
|
@ -69,137 +70,133 @@ class Command(ABC):
|
||||||
|
|
||||||
class UndoRedoHistory:
|
class UndoRedoHistory:
|
||||||
"""Manages undo/redo history with configurable limits.
|
"""Manages undo/redo history with configurable limits.
|
||||||
|
|
||||||
This class maintains separate stacks for undo and redo operations,
|
This class maintains separate stacks for undo and redo operations,
|
||||||
executes commands while managing history, and provides methods to
|
executes commands while managing history, and provides methods to
|
||||||
check availability of undo/redo operations.
|
check availability of undo/redo operations.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, max_history: int = 50):
|
def __init__(self, max_history: int = 50):
|
||||||
"""Initialize the history manager.
|
"""Initialize the history manager.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
max_history: Maximum number of commands to keep in history
|
max_history: Maximum number of commands to keep in history
|
||||||
"""
|
"""
|
||||||
self.max_history = max_history
|
self.max_history = max_history
|
||||||
self.undo_stack: list[Command] = []
|
self.undo_stack: list[Command] = []
|
||||||
self.redo_stack: list[Command] = []
|
self.redo_stack: list[Command] = []
|
||||||
|
|
||||||
def execute_command(self, command: Command, hosts_file: "HostsFile") -> OperationResult:
|
def execute_command(
|
||||||
|
self, command: Command, hosts_file: "HostsFile"
|
||||||
|
) -> OperationResult:
|
||||||
"""Execute a command and add it to the undo stack.
|
"""Execute a command and add it to the undo stack.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
command: Command to execute
|
command: Command to execute
|
||||||
hosts_file: The hosts file to operate on
|
hosts_file: The hosts file to operate on
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
OperationResult from command execution
|
OperationResult from command execution
|
||||||
"""
|
"""
|
||||||
result = command.execute(hosts_file)
|
result = command.execute(hosts_file)
|
||||||
|
|
||||||
if result.success:
|
if result.success:
|
||||||
# Add to undo stack and clear redo stack
|
# Add to undo stack and clear redo stack
|
||||||
self.undo_stack.append(command)
|
self.undo_stack.append(command)
|
||||||
self.redo_stack.clear()
|
self.redo_stack.clear()
|
||||||
|
|
||||||
# Enforce history limit
|
# Enforce history limit
|
||||||
if len(self.undo_stack) > self.max_history:
|
if len(self.undo_stack) > self.max_history:
|
||||||
self.undo_stack.pop(0)
|
self.undo_stack.pop(0)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def undo(self, hosts_file: "HostsFile") -> OperationResult:
|
def undo(self, hosts_file: "HostsFile") -> OperationResult:
|
||||||
"""Undo the last command.
|
"""Undo the last command.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
hosts_file: The hosts file to operate on
|
hosts_file: The hosts file to operate on
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
OperationResult from undo operation
|
OperationResult from undo operation
|
||||||
"""
|
"""
|
||||||
if not self.can_undo():
|
if not self.can_undo():
|
||||||
return OperationResult(
|
return OperationResult(success=False, message="No operations to undo")
|
||||||
success=False,
|
|
||||||
message="No operations to undo"
|
|
||||||
)
|
|
||||||
|
|
||||||
command = self.undo_stack.pop()
|
command = self.undo_stack.pop()
|
||||||
result = command.undo(hosts_file)
|
result = command.undo(hosts_file)
|
||||||
|
|
||||||
if result.success:
|
if result.success:
|
||||||
# Move command to redo stack
|
# Move command to redo stack
|
||||||
self.redo_stack.append(command)
|
self.redo_stack.append(command)
|
||||||
|
|
||||||
# Enforce history limit on redo stack too
|
# Enforce history limit on redo stack too
|
||||||
if len(self.redo_stack) > self.max_history:
|
if len(self.redo_stack) > self.max_history:
|
||||||
self.redo_stack.pop(0)
|
self.redo_stack.pop(0)
|
||||||
else:
|
else:
|
||||||
# If undo failed, put command back on undo stack
|
# If undo failed, put command back on undo stack
|
||||||
self.undo_stack.append(command)
|
self.undo_stack.append(command)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def redo(self, hosts_file: "HostsFile") -> OperationResult:
|
def redo(self, hosts_file: "HostsFile") -> OperationResult:
|
||||||
"""Redo the last undone command.
|
"""Redo the last undone command.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
hosts_file: The hosts file to operate on
|
hosts_file: The hosts file to operate on
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
OperationResult from redo operation
|
OperationResult from redo operation
|
||||||
"""
|
"""
|
||||||
if not self.can_redo():
|
if not self.can_redo():
|
||||||
return OperationResult(
|
return OperationResult(success=False, message="No operations to redo")
|
||||||
success=False,
|
|
||||||
message="No operations to redo"
|
|
||||||
)
|
|
||||||
|
|
||||||
command = self.redo_stack.pop()
|
command = self.redo_stack.pop()
|
||||||
result = command.execute(hosts_file)
|
result = command.execute(hosts_file)
|
||||||
|
|
||||||
if result.success:
|
if result.success:
|
||||||
# Move command back to undo stack
|
# Move command back to undo stack
|
||||||
self.undo_stack.append(command)
|
self.undo_stack.append(command)
|
||||||
else:
|
else:
|
||||||
# If redo failed, put command back on redo stack
|
# If redo failed, put command back on redo stack
|
||||||
self.redo_stack.append(command)
|
self.redo_stack.append(command)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def can_undo(self) -> bool:
|
def can_undo(self) -> bool:
|
||||||
"""Check if undo is possible.
|
"""Check if undo is possible.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if there are commands that can be undone
|
True if there are commands that can be undone
|
||||||
"""
|
"""
|
||||||
return len(self.undo_stack) > 0
|
return len(self.undo_stack) > 0
|
||||||
|
|
||||||
def can_redo(self) -> bool:
|
def can_redo(self) -> bool:
|
||||||
"""Check if redo is possible.
|
"""Check if redo is possible.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if there are commands that can be redone
|
True if there are commands that can be redone
|
||||||
"""
|
"""
|
||||||
return len(self.redo_stack) > 0
|
return len(self.redo_stack) > 0
|
||||||
|
|
||||||
def clear_history(self) -> None:
|
def clear_history(self) -> None:
|
||||||
"""Clear both undo and redo stacks."""
|
"""Clear both undo and redo stacks."""
|
||||||
self.undo_stack.clear()
|
self.undo_stack.clear()
|
||||||
self.redo_stack.clear()
|
self.redo_stack.clear()
|
||||||
|
|
||||||
def get_undo_description(self) -> Optional[str]:
|
def get_undo_description(self) -> Optional[str]:
|
||||||
"""Get description of the next command that would be undone.
|
"""Get description of the next command that would be undone.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Description string or None if no undo available
|
Description string or None if no undo available
|
||||||
"""
|
"""
|
||||||
if self.can_undo():
|
if self.can_undo():
|
||||||
return self.undo_stack[-1].get_description()
|
return self.undo_stack[-1].get_description()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_redo_description(self) -> Optional[str]:
|
def get_redo_description(self) -> Optional[str]:
|
||||||
"""Get description of the next command that would be redone.
|
"""Get description of the next command that would be redone.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Description string or None if no redo available
|
Description string or None if no redo available
|
||||||
"""
|
"""
|
||||||
|
|
@ -210,59 +207,56 @@ class UndoRedoHistory:
|
||||||
|
|
||||||
class ToggleEntryCommand(Command):
|
class ToggleEntryCommand(Command):
|
||||||
"""Command to toggle an entry's active state."""
|
"""Command to toggle an entry's active state."""
|
||||||
|
|
||||||
def __init__(self, index: int):
|
def __init__(self, index: int):
|
||||||
"""Initialize the toggle command.
|
"""Initialize the toggle command.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
index: Index of the entry to toggle
|
index: Index of the entry to toggle
|
||||||
"""
|
"""
|
||||||
self.index = index
|
self.index = index
|
||||||
self.original_state: Optional[bool] = None
|
self.original_state: Optional[bool] = None
|
||||||
|
|
||||||
def execute(self, hosts_file: "HostsFile") -> OperationResult:
|
def execute(self, hosts_file: "HostsFile") -> OperationResult:
|
||||||
"""Toggle the entry's active state."""
|
"""Toggle the entry's active state."""
|
||||||
if self.index < 0 or self.index >= len(hosts_file.entries):
|
if self.index < 0 or self.index >= len(hosts_file.entries):
|
||||||
return OperationResult(
|
return OperationResult(
|
||||||
success=False,
|
success=False, message=f"Invalid entry index: {self.index}"
|
||||||
message=f"Invalid entry index: {self.index}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
entry = hosts_file.entries[self.index]
|
entry = hosts_file.entries[self.index]
|
||||||
self.original_state = entry.is_active
|
self.original_state = entry.is_active
|
||||||
entry.is_active = not entry.is_active
|
entry.is_active = not entry.is_active
|
||||||
|
|
||||||
action = "activated" if entry.is_active else "deactivated"
|
action = "activated" if entry.is_active else "deactivated"
|
||||||
return OperationResult(
|
return OperationResult(
|
||||||
success=True,
|
success=True,
|
||||||
message=f"Entry {action}: {entry.ip_address} {' '.join(entry.hostnames)}",
|
message=f"Entry {action}: {entry.ip_address} {' '.join(entry.hostnames)}",
|
||||||
data={"index": self.index, "new_state": entry.is_active}
|
data={"index": self.index, "new_state": entry.is_active},
|
||||||
)
|
)
|
||||||
|
|
||||||
def undo(self, hosts_file: "HostsFile") -> OperationResult:
|
def undo(self, hosts_file: "HostsFile") -> OperationResult:
|
||||||
"""Restore the entry's original active state."""
|
"""Restore the entry's original active state."""
|
||||||
if self.original_state is None:
|
if self.original_state is None:
|
||||||
return OperationResult(
|
return OperationResult(
|
||||||
success=False,
|
success=False, message="Cannot undo: original state not saved"
|
||||||
message="Cannot undo: original state not saved"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.index < 0 or self.index >= len(hosts_file.entries):
|
if self.index < 0 or self.index >= len(hosts_file.entries):
|
||||||
return OperationResult(
|
return OperationResult(
|
||||||
success=False,
|
success=False, message=f"Cannot undo: invalid entry index: {self.index}"
|
||||||
message=f"Cannot undo: invalid entry index: {self.index}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
entry = hosts_file.entries[self.index]
|
entry = hosts_file.entries[self.index]
|
||||||
entry.is_active = self.original_state
|
entry.is_active = self.original_state
|
||||||
|
|
||||||
action = "activated" if entry.is_active else "deactivated"
|
action = "activated" if entry.is_active else "deactivated"
|
||||||
return OperationResult(
|
return OperationResult(
|
||||||
success=True,
|
success=True,
|
||||||
message=f"Undid toggle: entry {action}",
|
message=f"Undid toggle: entry {action}",
|
||||||
data={"index": self.index, "restored_state": entry.is_active}
|
data={"index": self.index, "restored_state": entry.is_active},
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_description(self) -> str:
|
def get_description(self) -> str:
|
||||||
"""Get description of this command."""
|
"""Get description of this command."""
|
||||||
return f"Toggle entry at index {self.index}"
|
return f"Toggle entry at index {self.index}"
|
||||||
|
|
@ -270,64 +264,75 @@ class ToggleEntryCommand(Command):
|
||||||
|
|
||||||
class MoveEntryCommand(Command):
|
class MoveEntryCommand(Command):
|
||||||
"""Command to move an entry up or down."""
|
"""Command to move an entry up or down."""
|
||||||
|
|
||||||
def __init__(self, from_index: int, to_index: int):
|
def __init__(self, from_index: int, to_index: int):
|
||||||
"""Initialize the move command.
|
"""Initialize the move command.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
from_index: Original position of the entry
|
from_index: Original position of the entry
|
||||||
to_index: Target position for the entry
|
to_index: Target position for the entry
|
||||||
"""
|
"""
|
||||||
self.from_index = from_index
|
self.from_index = from_index
|
||||||
self.to_index = to_index
|
self.to_index = to_index
|
||||||
|
|
||||||
def execute(self, hosts_file: "HostsFile") -> OperationResult:
|
def execute(self, hosts_file: "HostsFile") -> OperationResult:
|
||||||
"""Move the entry from one position to another."""
|
"""Move the entry from one position to another."""
|
||||||
if (self.from_index < 0 or self.from_index >= len(hosts_file.entries) or
|
if (
|
||||||
self.to_index < 0 or self.to_index >= len(hosts_file.entries)):
|
self.from_index < 0
|
||||||
|
or self.from_index >= len(hosts_file.entries)
|
||||||
|
or self.to_index < 0
|
||||||
|
or self.to_index >= len(hosts_file.entries)
|
||||||
|
):
|
||||||
return OperationResult(
|
return OperationResult(
|
||||||
success=False,
|
success=False,
|
||||||
message=f"Invalid move: from {self.from_index} to {self.to_index}"
|
message=f"Invalid move: from {self.from_index} to {self.to_index}",
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.from_index == self.to_index:
|
if self.from_index == self.to_index:
|
||||||
return OperationResult(
|
return OperationResult(
|
||||||
success=True,
|
success=True,
|
||||||
message="No movement needed",
|
message="No movement needed",
|
||||||
data={"from_index": self.from_index, "to_index": self.to_index}
|
data={"from_index": self.from_index, "to_index": self.to_index},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Move the entry
|
# Move the entry
|
||||||
entry = hosts_file.entries.pop(self.from_index)
|
entry = hosts_file.entries.pop(self.from_index)
|
||||||
hosts_file.entries.insert(self.to_index, entry)
|
hosts_file.entries.insert(self.to_index, entry)
|
||||||
|
|
||||||
direction = "up" if self.to_index < self.from_index else "down"
|
direction = "up" if self.to_index < self.from_index else "down"
|
||||||
return OperationResult(
|
return OperationResult(
|
||||||
success=True,
|
success=True,
|
||||||
message=f"Moved entry {direction}: {entry.ip_address} {' '.join(entry.hostnames)}",
|
message=f"Moved entry {direction}: {entry.ip_address} {' '.join(entry.hostnames)}",
|
||||||
data={"from_index": self.from_index, "to_index": self.to_index, "direction": direction}
|
data={
|
||||||
|
"from_index": self.from_index,
|
||||||
|
"to_index": self.to_index,
|
||||||
|
"direction": direction,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def undo(self, hosts_file: "HostsFile") -> OperationResult:
|
def undo(self, hosts_file: "HostsFile") -> OperationResult:
|
||||||
"""Move the entry back to its original position."""
|
"""Move the entry back to its original position."""
|
||||||
if (self.to_index < 0 or self.to_index >= len(hosts_file.entries) or
|
if (
|
||||||
self.from_index < 0 or self.from_index >= len(hosts_file.entries)):
|
self.to_index < 0
|
||||||
|
or self.to_index >= len(hosts_file.entries)
|
||||||
|
or self.from_index < 0
|
||||||
|
or self.from_index >= len(hosts_file.entries)
|
||||||
|
):
|
||||||
return OperationResult(
|
return OperationResult(
|
||||||
success=False,
|
success=False, message="Cannot undo move: invalid indices"
|
||||||
message="Cannot undo move: invalid indices"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Move back: from to_index back to from_index
|
# Move back: from to_index back to from_index
|
||||||
entry = hosts_file.entries.pop(self.to_index)
|
entry = hosts_file.entries.pop(self.to_index)
|
||||||
hosts_file.entries.insert(self.from_index, entry)
|
hosts_file.entries.insert(self.from_index, entry)
|
||||||
|
|
||||||
direction = "down" if self.to_index < self.from_index else "up"
|
direction = "down" if self.to_index < self.from_index else "up"
|
||||||
return OperationResult(
|
return OperationResult(
|
||||||
success=True,
|
success=True,
|
||||||
message=f"Undid move: moved entry {direction}",
|
message=f"Undid move: moved entry {direction}",
|
||||||
data={"restored_index": self.from_index}
|
data={"restored_index": self.from_index},
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_description(self) -> str:
|
def get_description(self) -> str:
|
||||||
"""Get description of this command."""
|
"""Get description of this command."""
|
||||||
direction = "up" if self.to_index < self.from_index else "down"
|
direction = "up" if self.to_index < self.from_index else "down"
|
||||||
|
|
@ -336,10 +341,10 @@ class MoveEntryCommand(Command):
|
||||||
|
|
||||||
class AddEntryCommand(Command):
|
class AddEntryCommand(Command):
|
||||||
"""Command to add a new entry."""
|
"""Command to add a new entry."""
|
||||||
|
|
||||||
def __init__(self, entry: "HostEntry", index: Optional[int] = None):
|
def __init__(self, entry: "HostEntry", index: Optional[int] = None):
|
||||||
"""Initialize the add command.
|
"""Initialize the add command.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
entry: The entry to add
|
entry: The entry to add
|
||||||
index: Position to insert at (None for end)
|
index: Position to insert at (None for end)
|
||||||
|
|
@ -347,7 +352,7 @@ class AddEntryCommand(Command):
|
||||||
self.entry = entry
|
self.entry = entry
|
||||||
self.index = index
|
self.index = index
|
||||||
self.actual_index: Optional[int] = None
|
self.actual_index: Optional[int] = None
|
||||||
|
|
||||||
def execute(self, hosts_file: "HostsFile") -> OperationResult:
|
def execute(self, hosts_file: "HostsFile") -> OperationResult:
|
||||||
"""Add the entry to the hosts file."""
|
"""Add the entry to the hosts file."""
|
||||||
if self.index is None:
|
if self.index is None:
|
||||||
|
|
@ -358,49 +363,49 @@ class AddEntryCommand(Command):
|
||||||
# Insert at specific position
|
# Insert at specific position
|
||||||
if self.index < 0 or self.index > len(hosts_file.entries):
|
if self.index < 0 or self.index > len(hosts_file.entries):
|
||||||
return OperationResult(
|
return OperationResult(
|
||||||
success=False,
|
success=False, message=f"Invalid insertion index: {self.index}"
|
||||||
message=f"Invalid insertion index: {self.index}"
|
|
||||||
)
|
)
|
||||||
hosts_file.entries.insert(self.index, self.entry)
|
hosts_file.entries.insert(self.index, self.entry)
|
||||||
self.actual_index = self.index
|
self.actual_index = self.index
|
||||||
|
|
||||||
return OperationResult(
|
return OperationResult(
|
||||||
success=True,
|
success=True,
|
||||||
message=f"Added entry: {self.entry.ip_address} {' '.join(self.entry.hostnames)}",
|
message=f"Added entry: {self.entry.ip_address} {' '.join(self.entry.hostnames)}",
|
||||||
data={"index": self.actual_index, "entry": self.entry}
|
data={"index": self.actual_index, "entry": self.entry},
|
||||||
)
|
)
|
||||||
|
|
||||||
def undo(self, hosts_file: "HostsFile") -> OperationResult:
|
def undo(self, hosts_file: "HostsFile") -> OperationResult:
|
||||||
"""Remove the added entry."""
|
"""Remove the added entry."""
|
||||||
if self.actual_index is None:
|
if self.actual_index is None:
|
||||||
return OperationResult(
|
return OperationResult(
|
||||||
success=False,
|
success=False, message="Cannot undo: entry index not recorded"
|
||||||
message="Cannot undo: entry index not recorded"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.actual_index < 0 or self.actual_index >= len(hosts_file.entries):
|
if self.actual_index < 0 or self.actual_index >= len(hosts_file.entries):
|
||||||
return OperationResult(
|
return OperationResult(
|
||||||
success=False,
|
success=False,
|
||||||
message=f"Cannot undo: invalid entry index: {self.actual_index}"
|
message=f"Cannot undo: invalid entry index: {self.actual_index}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify we're removing the right entry
|
# Verify we're removing the right entry
|
||||||
entry_to_remove = hosts_file.entries[self.actual_index]
|
entry_to_remove = hosts_file.entries[self.actual_index]
|
||||||
if (entry_to_remove.ip_address != self.entry.ip_address or
|
if (
|
||||||
entry_to_remove.hostnames != self.entry.hostnames):
|
entry_to_remove.ip_address != self.entry.ip_address
|
||||||
|
or entry_to_remove.hostnames != self.entry.hostnames
|
||||||
|
):
|
||||||
return OperationResult(
|
return OperationResult(
|
||||||
success=False,
|
success=False,
|
||||||
message="Cannot undo: entry at index doesn't match added entry"
|
message="Cannot undo: entry at index doesn't match added entry",
|
||||||
)
|
)
|
||||||
|
|
||||||
hosts_file.entries.pop(self.actual_index)
|
hosts_file.entries.pop(self.actual_index)
|
||||||
|
|
||||||
return OperationResult(
|
return OperationResult(
|
||||||
success=True,
|
success=True,
|
||||||
message=f"Undid add: removed entry {self.entry.ip_address}",
|
message=f"Undid add: removed entry {self.entry.ip_address}",
|
||||||
data={"removed_index": self.actual_index}
|
data={"removed_index": self.actual_index},
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_description(self) -> str:
|
def get_description(self) -> str:
|
||||||
"""Get description of this command."""
|
"""Get description of this command."""
|
||||||
return f"Add entry: {self.entry.ip_address} {' '.join(self.entry.hostnames)}"
|
return f"Add entry: {self.entry.ip_address} {' '.join(self.entry.hostnames)}"
|
||||||
|
|
@ -408,54 +413,52 @@ class AddEntryCommand(Command):
|
||||||
|
|
||||||
class DeleteEntryCommand(Command):
|
class DeleteEntryCommand(Command):
|
||||||
"""Command to delete an entry."""
|
"""Command to delete an entry."""
|
||||||
|
|
||||||
def __init__(self, index: int):
|
def __init__(self, index: int):
|
||||||
"""Initialize the delete command.
|
"""Initialize the delete command.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
index: Index of the entry to delete
|
index: Index of the entry to delete
|
||||||
"""
|
"""
|
||||||
self.index = index
|
self.index = index
|
||||||
self.deleted_entry: Optional["HostEntry"] = None
|
self.deleted_entry: Optional["HostEntry"] = None
|
||||||
|
|
||||||
def execute(self, hosts_file: "HostsFile") -> OperationResult:
|
def execute(self, hosts_file: "HostsFile") -> OperationResult:
|
||||||
"""Delete the entry from the hosts file."""
|
"""Delete the entry from the hosts file."""
|
||||||
if self.index < 0 or self.index >= len(hosts_file.entries):
|
if self.index < 0 or self.index >= len(hosts_file.entries):
|
||||||
return OperationResult(
|
return OperationResult(
|
||||||
success=False,
|
success=False, message=f"Invalid entry index: {self.index}"
|
||||||
message=f"Invalid entry index: {self.index}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.deleted_entry = hosts_file.entries.pop(self.index)
|
self.deleted_entry = hosts_file.entries.pop(self.index)
|
||||||
|
|
||||||
return OperationResult(
|
return OperationResult(
|
||||||
success=True,
|
success=True,
|
||||||
message=f"Deleted entry: {self.deleted_entry.ip_address} {' '.join(self.deleted_entry.hostnames)}",
|
message=f"Deleted entry: {self.deleted_entry.ip_address} {' '.join(self.deleted_entry.hostnames)}",
|
||||||
data={"index": self.index, "deleted_entry": self.deleted_entry}
|
data={"index": self.index, "deleted_entry": self.deleted_entry},
|
||||||
)
|
)
|
||||||
|
|
||||||
def undo(self, hosts_file: "HostsFile") -> OperationResult:
|
def undo(self, hosts_file: "HostsFile") -> OperationResult:
|
||||||
"""Restore the deleted entry."""
|
"""Restore the deleted entry."""
|
||||||
if self.deleted_entry is None:
|
if self.deleted_entry is None:
|
||||||
return OperationResult(
|
return OperationResult(
|
||||||
success=False,
|
success=False, message="Cannot undo: deleted entry not saved"
|
||||||
message="Cannot undo: deleted entry not saved"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.index < 0 or self.index > len(hosts_file.entries):
|
if self.index < 0 or self.index > len(hosts_file.entries):
|
||||||
return OperationResult(
|
return OperationResult(
|
||||||
success=False,
|
success=False,
|
||||||
message=f"Cannot undo: invalid restoration index: {self.index}"
|
message=f"Cannot undo: invalid restoration index: {self.index}",
|
||||||
)
|
)
|
||||||
|
|
||||||
hosts_file.entries.insert(self.index, self.deleted_entry)
|
hosts_file.entries.insert(self.index, self.deleted_entry)
|
||||||
|
|
||||||
return OperationResult(
|
return OperationResult(
|
||||||
success=True,
|
success=True,
|
||||||
message=f"Undid delete: restored entry {self.deleted_entry.ip_address}",
|
message=f"Undid delete: restored entry {self.deleted_entry.ip_address}",
|
||||||
data={"restored_index": self.index, "restored_entry": self.deleted_entry}
|
data={"restored_index": self.index, "restored_entry": self.deleted_entry},
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_description(self) -> str:
|
def get_description(self) -> str:
|
||||||
"""Get description of this command."""
|
"""Get description of this command."""
|
||||||
if self.deleted_entry:
|
if self.deleted_entry:
|
||||||
|
|
@ -465,11 +468,17 @@ class DeleteEntryCommand(Command):
|
||||||
|
|
||||||
class UpdateEntryCommand(Command):
|
class UpdateEntryCommand(Command):
|
||||||
"""Command to update an entry."""
|
"""Command to update an entry."""
|
||||||
|
|
||||||
def __init__(self, index: int, new_ip: str, new_hostnames: list[str],
|
def __init__(
|
||||||
new_comment: Optional[str], new_active: bool):
|
self,
|
||||||
|
index: int,
|
||||||
|
new_ip: str,
|
||||||
|
new_hostnames: list[str],
|
||||||
|
new_comment: Optional[str],
|
||||||
|
new_active: bool,
|
||||||
|
):
|
||||||
"""Initialize the update command.
|
"""Initialize the update command.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
index: Index of the entry to update
|
index: Index of the entry to update
|
||||||
new_ip: New IP address
|
new_ip: New IP address
|
||||||
|
|
@ -483,65 +492,63 @@ class UpdateEntryCommand(Command):
|
||||||
self.new_comment = new_comment
|
self.new_comment = new_comment
|
||||||
self.new_active = new_active
|
self.new_active = new_active
|
||||||
self.original_entry: Optional["HostEntry"] = None
|
self.original_entry: Optional["HostEntry"] = None
|
||||||
|
|
||||||
def execute(self, hosts_file: "HostsFile") -> OperationResult:
|
def execute(self, hosts_file: "HostsFile") -> OperationResult:
|
||||||
"""Update the entry with new values."""
|
"""Update the entry with new values."""
|
||||||
if self.index < 0 or self.index >= len(hosts_file.entries):
|
if self.index < 0 or self.index >= len(hosts_file.entries):
|
||||||
return OperationResult(
|
return OperationResult(
|
||||||
success=False,
|
success=False, message=f"Invalid entry index: {self.index}"
|
||||||
message=f"Invalid entry index: {self.index}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save original entry for undo
|
# Save original entry for undo
|
||||||
from .models import HostEntry
|
from .models import HostEntry
|
||||||
|
|
||||||
original = hosts_file.entries[self.index]
|
original = hosts_file.entries[self.index]
|
||||||
self.original_entry = HostEntry(
|
self.original_entry = HostEntry(
|
||||||
ip_address=original.ip_address,
|
ip_address=original.ip_address,
|
||||||
hostnames=original.hostnames.copy(),
|
hostnames=original.hostnames.copy(),
|
||||||
comment=original.comment,
|
comment=original.comment,
|
||||||
is_active=original.is_active
|
is_active=original.is_active,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update the entry
|
# Update the entry
|
||||||
entry = hosts_file.entries[self.index]
|
entry = hosts_file.entries[self.index]
|
||||||
entry.ip_address = self.new_ip
|
entry.ip_address = self.new_ip
|
||||||
entry.hostnames = self.new_hostnames.copy()
|
entry.hostnames = self.new_hostnames.copy()
|
||||||
entry.comment = self.new_comment
|
entry.comment = self.new_comment
|
||||||
entry.is_active = self.new_active
|
entry.is_active = self.new_active
|
||||||
|
|
||||||
return OperationResult(
|
return OperationResult(
|
||||||
success=True,
|
success=True,
|
||||||
message=f"Updated entry: {entry.ip_address} {' '.join(entry.hostnames)}",
|
message=f"Updated entry: {entry.ip_address} {' '.join(entry.hostnames)}",
|
||||||
data={"index": self.index, "updated_entry": entry}
|
data={"index": self.index, "updated_entry": entry},
|
||||||
)
|
)
|
||||||
|
|
||||||
def undo(self, hosts_file: "HostsFile") -> OperationResult:
|
def undo(self, hosts_file: "HostsFile") -> OperationResult:
|
||||||
"""Restore the entry's original values."""
|
"""Restore the entry's original values."""
|
||||||
if self.original_entry is None:
|
if self.original_entry is None:
|
||||||
return OperationResult(
|
return OperationResult(
|
||||||
success=False,
|
success=False, message="Cannot undo: original entry not saved"
|
||||||
message="Cannot undo: original entry not saved"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.index < 0 or self.index >= len(hosts_file.entries):
|
if self.index < 0 or self.index >= len(hosts_file.entries):
|
||||||
return OperationResult(
|
return OperationResult(
|
||||||
success=False,
|
success=False, message=f"Cannot undo: invalid entry index: {self.index}"
|
||||||
message=f"Cannot undo: invalid entry index: {self.index}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Restore original values
|
# Restore original values
|
||||||
entry = hosts_file.entries[self.index]
|
entry = hosts_file.entries[self.index]
|
||||||
entry.ip_address = self.original_entry.ip_address
|
entry.ip_address = self.original_entry.ip_address
|
||||||
entry.hostnames = self.original_entry.hostnames.copy()
|
entry.hostnames = self.original_entry.hostnames.copy()
|
||||||
entry.comment = self.original_entry.comment
|
entry.comment = self.original_entry.comment
|
||||||
entry.is_active = self.original_entry.is_active
|
entry.is_active = self.original_entry.is_active
|
||||||
|
|
||||||
return OperationResult(
|
return OperationResult(
|
||||||
success=True,
|
success=True,
|
||||||
message=f"Undid update: restored entry {entry.ip_address}",
|
message=f"Undid update: restored entry {entry.ip_address}",
|
||||||
data={"index": self.index, "restored_entry": entry}
|
data={"index": self.index, "restored_entry": entry},
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_description(self) -> str:
|
def get_description(self) -> str:
|
||||||
"""Get description of this command."""
|
"""Get description of this command."""
|
||||||
if self.original_entry:
|
if self.original_entry:
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ class Config:
|
||||||
},
|
},
|
||||||
"dns_resolution": {
|
"dns_resolution": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"timeout": 5.0, # 5 seconds timeout
|
"timeout": 5.0, # 5 seconds timeout
|
||||||
},
|
},
|
||||||
"filter_settings": {
|
"filter_settings": {
|
||||||
"remember_filter_state": True,
|
"remember_filter_state": True,
|
||||||
|
|
@ -201,7 +201,9 @@ class Config:
|
||||||
|
|
||||||
def get_export_directory(self) -> str:
|
def get_export_directory(self) -> str:
|
||||||
"""Get default export directory."""
|
"""Get default export directory."""
|
||||||
return self.get("import_export", {}).get("export_directory", str(Path.home() / "Downloads"))
|
return self.get("import_export", {}).get(
|
||||||
|
"export_directory", str(Path.home() / "Downloads")
|
||||||
|
)
|
||||||
|
|
||||||
def set_default_export_format(self, format_name: str) -> None:
|
def set_default_export_format(self, format_name: str) -> None:
|
||||||
"""Set default export format."""
|
"""Set default export format."""
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ logger = logging.getLogger(__name__)
|
||||||
@dataclass
|
@dataclass
|
||||||
class DNSResolutionStatus(Enum):
|
class DNSResolutionStatus(Enum):
|
||||||
"""Status of DNS resolution for an entry."""
|
"""Status of DNS resolution for an entry."""
|
||||||
|
|
||||||
NOT_RESOLVED = "not_resolved"
|
NOT_RESOLVED = "not_resolved"
|
||||||
RESOLVING = "resolving"
|
RESOLVING = "resolving"
|
||||||
RESOLVED = "resolved"
|
RESOLVED = "resolved"
|
||||||
|
|
@ -29,6 +30,7 @@ class DNSResolutionStatus(Enum):
|
||||||
@dataclass
|
@dataclass
|
||||||
class DNSResolution:
|
class DNSResolution:
|
||||||
"""Result of DNS resolution for a hostname."""
|
"""Result of DNS resolution for a hostname."""
|
||||||
|
|
||||||
hostname: str
|
hostname: str
|
||||||
resolved_ip: Optional[str]
|
resolved_ip: Optional[str]
|
||||||
status: DNSResolutionStatus
|
status: DNSResolutionStatus
|
||||||
|
|
@ -37,7 +39,9 @@ class DNSResolution:
|
||||||
|
|
||||||
def is_success(self) -> bool:
|
def is_success(self) -> bool:
|
||||||
"""Check if resolution was successful."""
|
"""Check if resolution was successful."""
|
||||||
return self.status == DNSResolutionStatus.RESOLVED and self.resolved_ip is not None
|
return (
|
||||||
|
self.status == DNSResolutionStatus.RESOLVED and self.resolved_ip is not None
|
||||||
|
)
|
||||||
|
|
||||||
def get_age_seconds(self) -> float:
|
def get_age_seconds(self) -> float:
|
||||||
"""Get age of resolution in seconds."""
|
"""Get age of resolution in seconds."""
|
||||||
|
|
@ -46,24 +50,23 @@ class DNSResolution:
|
||||||
|
|
||||||
async def resolve_hostname(hostname: str, timeout: float = 5.0) -> DNSResolution:
|
async def resolve_hostname(hostname: str, timeout: float = 5.0) -> DNSResolution:
|
||||||
"""Resolve a single hostname to IP address with timeout.
|
"""Resolve a single hostname to IP address with timeout.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
hostname: Hostname to resolve
|
hostname: Hostname to resolve
|
||||||
timeout: Maximum time to wait for resolution in seconds
|
timeout: Maximum time to wait for resolution in seconds
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
DNSResolution with result and status
|
DNSResolution with result and status
|
||||||
"""
|
"""
|
||||||
start_time = datetime.now()
|
start_time = datetime.now()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Use asyncio DNS resolution with timeout
|
# Use asyncio DNS resolution with timeout
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
result = await asyncio.wait_for(
|
result = await asyncio.wait_for(
|
||||||
loop.getaddrinfo(hostname, None, family=socket.AF_UNSPEC),
|
loop.getaddrinfo(hostname, None, family=socket.AF_UNSPEC), timeout=timeout
|
||||||
timeout=timeout
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
# Get first result (usually IPv4)
|
# Get first result (usually IPv4)
|
||||||
ip_address = result[0][4][0]
|
ip_address = result[0][4][0]
|
||||||
|
|
@ -71,7 +74,7 @@ async def resolve_hostname(hostname: str, timeout: float = 5.0) -> DNSResolution
|
||||||
hostname=hostname,
|
hostname=hostname,
|
||||||
resolved_ip=ip_address,
|
resolved_ip=ip_address,
|
||||||
status=DNSResolutionStatus.RESOLVED,
|
status=DNSResolutionStatus.RESOLVED,
|
||||||
resolved_at=start_time
|
resolved_at=start_time,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return DNSResolution(
|
return DNSResolution(
|
||||||
|
|
@ -79,16 +82,16 @@ async def resolve_hostname(hostname: str, timeout: float = 5.0) -> DNSResolution
|
||||||
resolved_ip=None,
|
resolved_ip=None,
|
||||||
status=DNSResolutionStatus.RESOLUTION_FAILED,
|
status=DNSResolutionStatus.RESOLUTION_FAILED,
|
||||||
resolved_at=start_time,
|
resolved_at=start_time,
|
||||||
error_message="No address found"
|
error_message="No address found",
|
||||||
)
|
)
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
return DNSResolution(
|
return DNSResolution(
|
||||||
hostname=hostname,
|
hostname=hostname,
|
||||||
resolved_ip=None,
|
resolved_ip=None,
|
||||||
status=DNSResolutionStatus.RESOLUTION_FAILED,
|
status=DNSResolutionStatus.RESOLUTION_FAILED,
|
||||||
resolved_at=start_time,
|
resolved_at=start_time,
|
||||||
error_message=f"Timeout after {timeout}s"
|
error_message=f"Timeout after {timeout}s",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return DNSResolution(
|
return DNSResolution(
|
||||||
|
|
@ -96,66 +99,66 @@ async def resolve_hostname(hostname: str, timeout: float = 5.0) -> DNSResolution
|
||||||
resolved_ip=None,
|
resolved_ip=None,
|
||||||
status=DNSResolutionStatus.RESOLUTION_FAILED,
|
status=DNSResolutionStatus.RESOLUTION_FAILED,
|
||||||
resolved_at=start_time,
|
resolved_at=start_time,
|
||||||
error_message=str(e)
|
error_message=str(e),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def resolve_hostnames_batch(hostnames: List[str], timeout: float = 5.0) -> List[DNSResolution]:
|
async def resolve_hostnames_batch(
|
||||||
|
hostnames: List[str], timeout: float = 5.0
|
||||||
|
) -> List[DNSResolution]:
|
||||||
"""Resolve multiple hostnames concurrently.
|
"""Resolve multiple hostnames concurrently.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
hostnames: List of hostnames to resolve
|
hostnames: List of hostnames to resolve
|
||||||
timeout: Maximum time to wait for each resolution
|
timeout: Maximum time to wait for each resolution
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of DNSResolution results
|
List of DNSResolution results
|
||||||
"""
|
"""
|
||||||
if not hostnames:
|
if not hostnames:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
tasks = [resolve_hostname(hostname, timeout) for hostname in hostnames]
|
tasks = [resolve_hostname(hostname, timeout) for hostname in hostnames]
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
# Convert exceptions to failed resolutions
|
# Convert exceptions to failed resolutions
|
||||||
resolutions = []
|
resolutions = []
|
||||||
for i, result in enumerate(results):
|
for i, result in enumerate(results):
|
||||||
if isinstance(result, Exception):
|
if isinstance(result, Exception):
|
||||||
resolutions.append(DNSResolution(
|
resolutions.append(
|
||||||
hostname=hostnames[i],
|
DNSResolution(
|
||||||
resolved_ip=None,
|
hostname=hostnames[i],
|
||||||
status=DNSResolutionStatus.RESOLUTION_FAILED,
|
resolved_ip=None,
|
||||||
resolved_at=datetime.now(),
|
status=DNSResolutionStatus.RESOLUTION_FAILED,
|
||||||
error_message=str(result)
|
resolved_at=datetime.now(),
|
||||||
))
|
error_message=str(result),
|
||||||
|
)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
resolutions.append(result)
|
resolutions.append(result)
|
||||||
|
|
||||||
return resolutions
|
return resolutions
|
||||||
|
|
||||||
|
|
||||||
class DNSService:
|
class DNSService:
|
||||||
"""DNS resolution service for hosts entries."""
|
"""DNS resolution service for hosts entries."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, enabled: bool = True, timeout: float = 5.0):
|
||||||
self,
|
|
||||||
enabled: bool = True,
|
|
||||||
timeout: float = 5.0
|
|
||||||
):
|
|
||||||
"""Initialize DNS service.
|
"""Initialize DNS service.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
enabled: Whether DNS resolution is enabled
|
enabled: Whether DNS resolution is enabled
|
||||||
timeout: Timeout for individual DNS queries
|
timeout: Timeout for individual DNS queries
|
||||||
"""
|
"""
|
||||||
self.enabled = enabled
|
self.enabled = enabled
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
|
||||||
async def resolve_entry_async(self, hostname: str) -> DNSResolution:
|
async def resolve_entry_async(self, hostname: str) -> DNSResolution:
|
||||||
"""Resolve DNS for a hostname asynchronously.
|
"""Resolve DNS for a hostname asynchronously.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
hostname: Hostname to resolve
|
hostname: Hostname to resolve
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
DNSResolution result
|
DNSResolution result
|
||||||
"""
|
"""
|
||||||
|
|
@ -165,28 +168,28 @@ class DNSService:
|
||||||
resolved_ip=None,
|
resolved_ip=None,
|
||||||
status=DNSResolutionStatus.NOT_RESOLVED,
|
status=DNSResolutionStatus.NOT_RESOLVED,
|
||||||
resolved_at=datetime.now(),
|
resolved_at=datetime.now(),
|
||||||
error_message="DNS resolution is disabled"
|
error_message="DNS resolution is disabled",
|
||||||
)
|
)
|
||||||
|
|
||||||
return await resolve_hostname(hostname, self.timeout)
|
return await resolve_hostname(hostname, self.timeout)
|
||||||
|
|
||||||
async def refresh_entry(self, hostname: str) -> DNSResolution:
|
async def refresh_entry(self, hostname: str) -> DNSResolution:
|
||||||
"""Manually refresh DNS resolution for hostname.
|
"""Manually refresh DNS resolution for hostname.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
hostname: Hostname to refresh
|
hostname: Hostname to refresh
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Fresh DNSResolution result
|
Fresh DNSResolution result
|
||||||
"""
|
"""
|
||||||
return await self.resolve_entry_async(hostname)
|
return await self.resolve_entry_async(hostname)
|
||||||
|
|
||||||
async def refresh_all_entries(self, hostnames: List[str]) -> List[DNSResolution]:
|
async def refresh_all_entries(self, hostnames: List[str]) -> List[DNSResolution]:
|
||||||
"""Manually refresh DNS resolution for multiple hostnames.
|
"""Manually refresh DNS resolution for multiple hostnames.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
hostnames: List of hostnames to refresh
|
hostnames: List of hostnames to refresh
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of fresh DNSResolution results
|
List of fresh DNSResolution results
|
||||||
"""
|
"""
|
||||||
|
|
@ -197,21 +200,21 @@ class DNSService:
|
||||||
resolved_ip=None,
|
resolved_ip=None,
|
||||||
status=DNSResolutionStatus.NOT_RESOLVED,
|
status=DNSResolutionStatus.NOT_RESOLVED,
|
||||||
resolved_at=datetime.now(),
|
resolved_at=datetime.now(),
|
||||||
error_message="DNS resolution is disabled"
|
error_message="DNS resolution is disabled",
|
||||||
)
|
)
|
||||||
for hostname in hostnames
|
for hostname in hostnames
|
||||||
]
|
]
|
||||||
|
|
||||||
return await resolve_hostnames_batch(hostnames, self.timeout)
|
return await resolve_hostnames_batch(hostnames, self.timeout)
|
||||||
|
|
||||||
|
|
||||||
def compare_ips(stored_ip: str, resolved_ip: str) -> DNSResolutionStatus:
|
def compare_ips(stored_ip: str, resolved_ip: str) -> DNSResolutionStatus:
|
||||||
"""Compare stored IP with resolved IP to determine status.
|
"""Compare stored IP with resolved IP to determine status.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
stored_ip: IP address stored in hosts entry
|
stored_ip: IP address stored in hosts entry
|
||||||
resolved_ip: IP address resolved from DNS
|
resolved_ip: IP address resolved from DNS
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
DNSResolutionStatus indicating match or mismatch
|
DNSResolutionStatus indicating match or mismatch
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from .models import HostEntry
|
||||||
|
|
||||||
class FilterType(Enum):
|
class FilterType(Enum):
|
||||||
"""Filter type enumeration."""
|
"""Filter type enumeration."""
|
||||||
|
|
||||||
STATUS = "status"
|
STATUS = "status"
|
||||||
DNS_TYPE = "dns_type"
|
DNS_TYPE = "dns_type"
|
||||||
RESOLUTION_STATUS = "resolution_status"
|
RESOLUTION_STATUS = "resolution_status"
|
||||||
|
|
@ -23,18 +24,19 @@ class FilterType(Enum):
|
||||||
@dataclass
|
@dataclass
|
||||||
class FilterOptions:
|
class FilterOptions:
|
||||||
"""Configuration options for filtering entries."""
|
"""Configuration options for filtering entries."""
|
||||||
|
|
||||||
# Status filtering
|
# Status filtering
|
||||||
show_active: bool = True
|
show_active: bool = True
|
||||||
show_inactive: bool = True
|
show_inactive: bool = True
|
||||||
active_only: bool = False
|
active_only: bool = False
|
||||||
inactive_only: bool = False
|
inactive_only: bool = False
|
||||||
|
|
||||||
# DNS type filtering
|
# DNS type filtering
|
||||||
show_dns_entries: bool = True
|
show_dns_entries: bool = True
|
||||||
show_ip_entries: bool = True
|
show_ip_entries: bool = True
|
||||||
dns_only: bool = False
|
dns_only: bool = False
|
||||||
ip_only: bool = False
|
ip_only: bool = False
|
||||||
|
|
||||||
# DNS resolution status filtering
|
# DNS resolution status filtering
|
||||||
show_resolved: bool = True
|
show_resolved: bool = True
|
||||||
show_unresolved: bool = True
|
show_unresolved: bool = True
|
||||||
|
|
@ -43,167 +45,177 @@ class FilterOptions:
|
||||||
show_mismatched: bool = True
|
show_mismatched: bool = True
|
||||||
mismatch_only: bool = False
|
mismatch_only: bool = False
|
||||||
resolved_only: bool = False
|
resolved_only: bool = False
|
||||||
|
|
||||||
# Search filtering
|
# Search filtering
|
||||||
search_term: Optional[str] = None
|
search_term: Optional[str] = None
|
||||||
search_in_hostnames: bool = True
|
search_in_hostnames: bool = True
|
||||||
search_in_comments: bool = True
|
search_in_comments: bool = True
|
||||||
search_in_ips: bool = True
|
search_in_ips: bool = True
|
||||||
case_sensitive: bool = False
|
case_sensitive: bool = False
|
||||||
|
|
||||||
# Filter preset
|
# Filter preset
|
||||||
preset_name: Optional[str] = None
|
preset_name: Optional[str] = None
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
"""Convert FilterOptions to dictionary."""
|
"""Convert FilterOptions to dictionary."""
|
||||||
return {
|
return {
|
||||||
'show_active': self.show_active,
|
"show_active": self.show_active,
|
||||||
'show_inactive': self.show_inactive,
|
"show_inactive": self.show_inactive,
|
||||||
'active_only': self.active_only,
|
"active_only": self.active_only,
|
||||||
'inactive_only': self.inactive_only,
|
"inactive_only": self.inactive_only,
|
||||||
'show_dns_entries': self.show_dns_entries,
|
"show_dns_entries": self.show_dns_entries,
|
||||||
'show_ip_entries': self.show_ip_entries,
|
"show_ip_entries": self.show_ip_entries,
|
||||||
'dns_only': self.dns_only,
|
"dns_only": self.dns_only,
|
||||||
'ip_only': self.ip_only,
|
"ip_only": self.ip_only,
|
||||||
'show_resolved': self.show_resolved,
|
"show_resolved": self.show_resolved,
|
||||||
'show_unresolved': self.show_unresolved,
|
"show_unresolved": self.show_unresolved,
|
||||||
'show_resolving': self.show_resolving,
|
"show_resolving": self.show_resolving,
|
||||||
'show_failed': self.show_failed,
|
"show_failed": self.show_failed,
|
||||||
'show_mismatched': self.show_mismatched,
|
"show_mismatched": self.show_mismatched,
|
||||||
'mismatch_only': self.mismatch_only,
|
"mismatch_only": self.mismatch_only,
|
||||||
'resolved_only': self.resolved_only,
|
"resolved_only": self.resolved_only,
|
||||||
'search_term': self.search_term or "",
|
"search_term": self.search_term or "",
|
||||||
'search_in_hostnames': self.search_in_hostnames,
|
"search_in_hostnames": self.search_in_hostnames,
|
||||||
'search_in_comments': self.search_in_comments,
|
"search_in_comments": self.search_in_comments,
|
||||||
'search_in_ips': self.search_in_ips,
|
"search_in_ips": self.search_in_ips,
|
||||||
'case_sensitive': self.case_sensitive,
|
"case_sensitive": self.case_sensitive,
|
||||||
'preset_name': self.preset_name
|
"preset_name": self.preset_name,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: Dict[str, Any]) -> 'FilterOptions':
|
def from_dict(cls, data: Dict[str, Any]) -> "FilterOptions":
|
||||||
"""Create FilterOptions from dictionary."""
|
"""Create FilterOptions from dictionary."""
|
||||||
return cls(
|
return cls(
|
||||||
show_active=data.get('show_active', True),
|
show_active=data.get("show_active", True),
|
||||||
show_inactive=data.get('show_inactive', True),
|
show_inactive=data.get("show_inactive", True),
|
||||||
active_only=data.get('active_only', False),
|
active_only=data.get("active_only", False),
|
||||||
inactive_only=data.get('inactive_only', False),
|
inactive_only=data.get("inactive_only", False),
|
||||||
show_dns_entries=data.get('show_dns_entries', True),
|
show_dns_entries=data.get("show_dns_entries", True),
|
||||||
show_ip_entries=data.get('show_ip_entries', True),
|
show_ip_entries=data.get("show_ip_entries", True),
|
||||||
dns_only=data.get('dns_only', False),
|
dns_only=data.get("dns_only", False),
|
||||||
ip_only=data.get('ip_only', False),
|
ip_only=data.get("ip_only", False),
|
||||||
show_resolved=data.get('show_resolved', True),
|
show_resolved=data.get("show_resolved", True),
|
||||||
show_unresolved=data.get('show_unresolved', True),
|
show_unresolved=data.get("show_unresolved", True),
|
||||||
show_resolving=data.get('show_resolving', True),
|
show_resolving=data.get("show_resolving", True),
|
||||||
show_failed=data.get('show_failed', True),
|
show_failed=data.get("show_failed", True),
|
||||||
show_mismatched=data.get('show_mismatched', True),
|
show_mismatched=data.get("show_mismatched", True),
|
||||||
mismatch_only=data.get('mismatch_only', False),
|
mismatch_only=data.get("mismatch_only", False),
|
||||||
resolved_only=data.get('resolved_only', False),
|
resolved_only=data.get("resolved_only", False),
|
||||||
search_term=data.get('search_term', None),
|
search_term=data.get("search_term", None),
|
||||||
search_in_hostnames=data.get('search_in_hostnames', True),
|
search_in_hostnames=data.get("search_in_hostnames", True),
|
||||||
search_in_comments=data.get('search_in_comments', True),
|
search_in_comments=data.get("search_in_comments", True),
|
||||||
search_in_ips=data.get('search_in_ips', True),
|
search_in_ips=data.get("search_in_ips", True),
|
||||||
case_sensitive=data.get('case_sensitive', False),
|
case_sensitive=data.get("case_sensitive", False),
|
||||||
preset_name=data.get('preset_name', None)
|
preset_name=data.get("preset_name", None),
|
||||||
)
|
)
|
||||||
|
|
||||||
def is_empty(self) -> bool:
|
def is_empty(self) -> bool:
|
||||||
"""Check if filter options represent no filtering (default state)."""
|
"""Check if filter options represent no filtering (default state)."""
|
||||||
return (
|
return (
|
||||||
self.show_active and self.show_inactive and
|
self.show_active
|
||||||
not self.active_only and not self.inactive_only and
|
and self.show_inactive
|
||||||
self.show_dns_entries and self.show_ip_entries and
|
and not self.active_only
|
||||||
not self.dns_only and not self.ip_only and
|
and not self.inactive_only
|
||||||
self.show_resolved and self.show_unresolved and
|
and self.show_dns_entries
|
||||||
self.show_resolving and self.show_failed and self.show_mismatched and
|
and self.show_ip_entries
|
||||||
not self.mismatch_only and not self.resolved_only and
|
and not self.dns_only
|
||||||
not self.search_term
|
and not self.ip_only
|
||||||
|
and self.show_resolved
|
||||||
|
and self.show_unresolved
|
||||||
|
and self.show_resolving
|
||||||
|
and self.show_failed
|
||||||
|
and self.show_mismatched
|
||||||
|
and not self.mismatch_only
|
||||||
|
and not self.resolved_only
|
||||||
|
and not self.search_term
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class EntryFilter:
|
class EntryFilter:
|
||||||
"""Advanced filtering logic for hosts entries."""
|
"""Advanced filtering logic for hosts entries."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize the entry filter."""
|
"""Initialize the entry filter."""
|
||||||
self.presets: Dict[str, FilterOptions] = {}
|
self.presets: Dict[str, FilterOptions] = {}
|
||||||
self._load_default_presets()
|
self._load_default_presets()
|
||||||
|
|
||||||
def _load_default_presets(self) -> None:
|
def _load_default_presets(self) -> None:
|
||||||
"""Load default filter presets."""
|
"""Load default filter presets."""
|
||||||
self.presets = {
|
self.presets = {
|
||||||
"All Entries": FilterOptions(),
|
"All Entries": FilterOptions(),
|
||||||
"Active Only": FilterOptions(
|
"Active Only": FilterOptions(show_inactive=False, active_only=True),
|
||||||
show_inactive=False,
|
"Inactive Only": FilterOptions(show_active=False, inactive_only=True),
|
||||||
active_only=True
|
"DNS Entries Only": FilterOptions(show_ip_entries=False, dns_only=True),
|
||||||
),
|
"IP Entries Only": FilterOptions(show_dns_entries=False, ip_only=True),
|
||||||
"Inactive Only": FilterOptions(
|
"DNS Mismatches": FilterOptions(mismatch_only=True),
|
||||||
show_active=False,
|
|
||||||
inactive_only=True
|
|
||||||
),
|
|
||||||
"DNS Entries Only": FilterOptions(
|
|
||||||
show_ip_entries=False,
|
|
||||||
dns_only=True
|
|
||||||
),
|
|
||||||
"IP Entries Only": FilterOptions(
|
|
||||||
show_dns_entries=False,
|
|
||||||
ip_only=True
|
|
||||||
),
|
|
||||||
"DNS Mismatches": FilterOptions(
|
|
||||||
mismatch_only=True
|
|
||||||
),
|
|
||||||
"Resolution Failed": FilterOptions(
|
"Resolution Failed": FilterOptions(
|
||||||
show_resolved=False,
|
show_resolved=False,
|
||||||
show_unresolved=False,
|
show_unresolved=False,
|
||||||
show_resolving=False,
|
show_resolving=False,
|
||||||
show_mismatched=False
|
show_mismatched=False,
|
||||||
),
|
),
|
||||||
"Needs Resolution": FilterOptions(
|
"Needs Resolution": FilterOptions(
|
||||||
show_resolved=False,
|
show_resolved=False, show_failed=False, show_mismatched=False
|
||||||
show_failed=False,
|
),
|
||||||
show_mismatched=False
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def apply_filters(self, entries: List[HostEntry], options: FilterOptions) -> List[HostEntry]:
|
def apply_filters(
|
||||||
|
self, entries: List[HostEntry], options: FilterOptions
|
||||||
|
) -> List[HostEntry]:
|
||||||
"""
|
"""
|
||||||
Apply all filter criteria to the list of entries.
|
Apply all filter criteria to the list of entries.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
entries: List of host entries to filter
|
entries: List of host entries to filter
|
||||||
options: Filter configuration options
|
options: Filter configuration options
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Filtered list of entries
|
Filtered list of entries
|
||||||
"""
|
"""
|
||||||
filtered_entries = entries.copy()
|
filtered_entries = entries.copy()
|
||||||
|
|
||||||
# Apply status filtering
|
# Apply status filtering
|
||||||
if options.active_only or options.inactive_only or not (options.show_active and options.show_inactive):
|
if (
|
||||||
|
options.active_only
|
||||||
|
or options.inactive_only
|
||||||
|
or not (options.show_active and options.show_inactive)
|
||||||
|
):
|
||||||
filtered_entries = self.filter_by_status(filtered_entries, options)
|
filtered_entries = self.filter_by_status(filtered_entries, options)
|
||||||
|
|
||||||
# Apply DNS type filtering
|
# Apply DNS type filtering
|
||||||
if options.dns_only or options.ip_only or not (options.show_dns_entries and options.show_ip_entries):
|
if (
|
||||||
|
options.dns_only
|
||||||
|
or options.ip_only
|
||||||
|
or not (options.show_dns_entries and options.show_ip_entries)
|
||||||
|
):
|
||||||
filtered_entries = self.filter_by_dns_type(filtered_entries, options)
|
filtered_entries = self.filter_by_dns_type(filtered_entries, options)
|
||||||
|
|
||||||
# Apply DNS resolution status filtering
|
# Apply DNS resolution status filtering
|
||||||
if options.mismatch_only or options.resolved_only or not self._all_resolution_status_shown(options):
|
if (
|
||||||
filtered_entries = self.filter_by_resolution_status(filtered_entries, options)
|
options.mismatch_only
|
||||||
|
or options.resolved_only
|
||||||
|
or not self._all_resolution_status_shown(options)
|
||||||
|
):
|
||||||
|
filtered_entries = self.filter_by_resolution_status(
|
||||||
|
filtered_entries, options
|
||||||
|
)
|
||||||
|
|
||||||
# Apply search filtering
|
# Apply search filtering
|
||||||
if options.search_term:
|
if options.search_term:
|
||||||
filtered_entries = self.filter_by_search(filtered_entries, options)
|
filtered_entries = self.filter_by_search(filtered_entries, options)
|
||||||
|
|
||||||
return filtered_entries
|
return filtered_entries
|
||||||
|
|
||||||
def filter_by_status(self, entries: List[HostEntry], options: FilterOptions) -> List[HostEntry]:
|
def filter_by_status(
|
||||||
|
self, entries: List[HostEntry], options: FilterOptions
|
||||||
|
) -> List[HostEntry]:
|
||||||
"""
|
"""
|
||||||
Filter entries by active/inactive status.
|
Filter entries by active/inactive status.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
entries: List of entries to filter
|
entries: List of entries to filter
|
||||||
options: Filter options containing status criteria
|
options: Filter options containing status criteria
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Filtered list of entries
|
Filtered list of entries
|
||||||
"""
|
"""
|
||||||
|
|
@ -220,15 +232,17 @@ class EntryFilter:
|
||||||
elif not entry.is_active and options.show_inactive:
|
elif not entry.is_active and options.show_inactive:
|
||||||
filtered.append(entry)
|
filtered.append(entry)
|
||||||
return filtered
|
return filtered
|
||||||
|
|
||||||
def filter_by_dns_type(self, entries: List[HostEntry], options: FilterOptions) -> List[HostEntry]:
|
def filter_by_dns_type(
|
||||||
|
self, entries: List[HostEntry], options: FilterOptions
|
||||||
|
) -> List[HostEntry]:
|
||||||
"""
|
"""
|
||||||
Filter entries by DNS name vs IP address type.
|
Filter entries by DNS name vs IP address type.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
entries: List of entries to filter
|
entries: List of entries to filter
|
||||||
options: Filter options containing DNS type criteria
|
options: Filter options containing DNS type criteria
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Filtered list of entries
|
Filtered list of entries
|
||||||
"""
|
"""
|
||||||
|
|
@ -245,61 +259,73 @@ class EntryFilter:
|
||||||
elif not entry.has_dns_name() and options.show_ip_entries:
|
elif not entry.has_dns_name() and options.show_ip_entries:
|
||||||
filtered.append(entry)
|
filtered.append(entry)
|
||||||
return filtered
|
return filtered
|
||||||
|
|
||||||
def filter_by_resolution_status(self, entries: List[HostEntry], options: FilterOptions) -> List[HostEntry]:
|
def filter_by_resolution_status(
|
||||||
|
self, entries: List[HostEntry], options: FilterOptions
|
||||||
|
) -> List[HostEntry]:
|
||||||
"""
|
"""
|
||||||
Filter entries by DNS resolution status.
|
Filter entries by DNS resolution status.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
entries: List of entries to filter
|
entries: List of entries to filter
|
||||||
options: Filter options containing resolution status criteria
|
options: Filter options containing resolution status criteria
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Filtered list of entries
|
Filtered list of entries
|
||||||
"""
|
"""
|
||||||
if options.mismatch_only:
|
if options.mismatch_only:
|
||||||
return [entry for entry in entries
|
return [
|
||||||
if entry.dns_resolution_status == "IP_MISMATCH"]
|
entry
|
||||||
|
for entry in entries
|
||||||
|
if entry.dns_resolution_status == "IP_MISMATCH"
|
||||||
|
]
|
||||||
elif options.resolved_only:
|
elif options.resolved_only:
|
||||||
return [entry for entry in entries
|
return [
|
||||||
if entry.dns_resolution_status in ["IP_MATCH", "RESOLVED"]]
|
entry
|
||||||
|
for entry in entries
|
||||||
|
if entry.dns_resolution_status in ["IP_MATCH", "RESOLVED"]
|
||||||
|
]
|
||||||
else:
|
else:
|
||||||
# Show based on individual flags
|
# Show based on individual flags
|
||||||
filtered = []
|
filtered = []
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
status = entry.dns_resolution_status or "NOT_RESOLVED"
|
status = entry.dns_resolution_status or "NOT_RESOLVED"
|
||||||
|
|
||||||
if (status == "NOT_RESOLVED" and options.show_unresolved) or \
|
if (
|
||||||
(status == "RESOLVING" and options.show_resolving) or \
|
(status == "NOT_RESOLVED" and options.show_unresolved)
|
||||||
(status in ["IP_MATCH", "RESOLVED"] and options.show_resolved) or \
|
or (status == "RESOLVING" and options.show_resolving)
|
||||||
(status == "RESOLUTION_FAILED" and options.show_failed) or \
|
or (status in ["IP_MATCH", "RESOLVED"] and options.show_resolved)
|
||||||
(status == "IP_MISMATCH" and options.show_mismatched):
|
or (status == "RESOLUTION_FAILED" and options.show_failed)
|
||||||
|
or (status == "IP_MISMATCH" and options.show_mismatched)
|
||||||
|
):
|
||||||
filtered.append(entry)
|
filtered.append(entry)
|
||||||
|
|
||||||
return filtered
|
return filtered
|
||||||
|
|
||||||
def filter_by_search(self, entries: List[HostEntry], options: FilterOptions) -> List[HostEntry]:
|
def filter_by_search(
|
||||||
|
self, entries: List[HostEntry], options: FilterOptions
|
||||||
|
) -> List[HostEntry]:
|
||||||
"""
|
"""
|
||||||
Filter entries by search term.
|
Filter entries by search term.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
entries: List of entries to filter
|
entries: List of entries to filter
|
||||||
options: Filter options containing search criteria
|
options: Filter options containing search criteria
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Filtered list of entries
|
Filtered list of entries
|
||||||
"""
|
"""
|
||||||
if not options.search_term:
|
if not options.search_term:
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
search_term = options.search_term
|
search_term = options.search_term
|
||||||
if not options.case_sensitive:
|
if not options.case_sensitive:
|
||||||
search_term = search_term.lower()
|
search_term = search_term.lower()
|
||||||
|
|
||||||
filtered = []
|
filtered = []
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
match_found = False
|
match_found = False
|
||||||
|
|
||||||
# Search in hostnames
|
# Search in hostnames
|
||||||
if options.search_in_hostnames:
|
if options.search_in_hostnames:
|
||||||
hostnames_text = " ".join(entry.hostnames)
|
hostnames_text = " ".join(entry.hostnames)
|
||||||
|
|
@ -307,7 +333,7 @@ class EntryFilter:
|
||||||
hostnames_text = hostnames_text.lower()
|
hostnames_text = hostnames_text.lower()
|
||||||
if search_term in hostnames_text:
|
if search_term in hostnames_text:
|
||||||
match_found = True
|
match_found = True
|
||||||
|
|
||||||
# Search in comments
|
# Search in comments
|
||||||
if not match_found and options.search_in_comments and entry.comment:
|
if not match_found and options.search_in_comments and entry.comment:
|
||||||
comment_text = entry.comment
|
comment_text = entry.comment
|
||||||
|
|
@ -315,7 +341,7 @@ class EntryFilter:
|
||||||
comment_text = comment_text.lower()
|
comment_text = comment_text.lower()
|
||||||
if search_term in comment_text:
|
if search_term in comment_text:
|
||||||
match_found = True
|
match_found = True
|
||||||
|
|
||||||
# Search in IP addresses
|
# Search in IP addresses
|
||||||
if not match_found and options.search_in_ips:
|
if not match_found and options.search_in_ips:
|
||||||
ip_text = entry.ip_address or ""
|
ip_text = entry.ip_address or ""
|
||||||
|
|
@ -325,22 +351,26 @@ class EntryFilter:
|
||||||
ip_text = ip_text.lower()
|
ip_text = ip_text.lower()
|
||||||
if search_term in ip_text:
|
if search_term in ip_text:
|
||||||
match_found = True
|
match_found = True
|
||||||
|
|
||||||
if match_found:
|
if match_found:
|
||||||
filtered.append(entry)
|
filtered.append(entry)
|
||||||
|
|
||||||
return filtered
|
return filtered
|
||||||
|
|
||||||
def _all_resolution_status_shown(self, options: FilterOptions) -> bool:
|
def _all_resolution_status_shown(self, options: FilterOptions) -> bool:
|
||||||
"""Check if all resolution status types are shown."""
|
"""Check if all resolution status types are shown."""
|
||||||
return (options.show_resolved and options.show_unresolved and
|
return (
|
||||||
options.show_resolving and options.show_failed and
|
options.show_resolved
|
||||||
options.show_mismatched)
|
and options.show_unresolved
|
||||||
|
and options.show_resolving
|
||||||
|
and options.show_failed
|
||||||
|
and options.show_mismatched
|
||||||
|
)
|
||||||
|
|
||||||
def save_preset(self, name: str, options: FilterOptions) -> None:
|
def save_preset(self, name: str, options: FilterOptions) -> None:
|
||||||
"""
|
"""
|
||||||
Save filter options as a preset.
|
Save filter options as a preset.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: Name for the preset
|
name: Name for the preset
|
||||||
options: Filter options to save
|
options: Filter options to save
|
||||||
|
|
@ -367,29 +397,29 @@ class EntryFilter:
|
||||||
search_in_comments=options.search_in_comments,
|
search_in_comments=options.search_in_comments,
|
||||||
search_in_ips=options.search_in_ips,
|
search_in_ips=options.search_in_ips,
|
||||||
case_sensitive=options.case_sensitive,
|
case_sensitive=options.case_sensitive,
|
||||||
preset_name=name
|
preset_name=name,
|
||||||
)
|
)
|
||||||
self.presets[name] = preset_options
|
self.presets[name] = preset_options
|
||||||
|
|
||||||
def load_preset(self, name: str) -> Optional[FilterOptions]:
|
def load_preset(self, name: str) -> Optional[FilterOptions]:
|
||||||
"""
|
"""
|
||||||
Load filter options from a preset.
|
Load filter options from a preset.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: Name of the preset to load
|
name: Name of the preset to load
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Filter options if preset exists, None otherwise
|
Filter options if preset exists, None otherwise
|
||||||
"""
|
"""
|
||||||
return self.presets.get(name)
|
return self.presets.get(name)
|
||||||
|
|
||||||
def delete_preset(self, name: str) -> bool:
|
def delete_preset(self, name: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Delete a preset.
|
Delete a preset.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: Name of the preset to delete
|
name: Name of the preset to delete
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if preset was deleted, False if it didn't exist
|
True if preset was deleted, False if it didn't exist
|
||||||
"""
|
"""
|
||||||
|
|
@ -397,99 +427,102 @@ class EntryFilter:
|
||||||
del self.presets[name]
|
del self.presets[name]
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_preset_names(self) -> List[str]:
|
def get_preset_names(self) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Get list of available preset names.
|
Get list of available preset names.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of preset names
|
List of preset names
|
||||||
"""
|
"""
|
||||||
return list(self.presets.keys())
|
return list(self.presets.keys())
|
||||||
|
|
||||||
def get_default_presets(self) -> Dict[str, FilterOptions]:
|
def get_default_presets(self) -> Dict[str, FilterOptions]:
|
||||||
"""
|
"""
|
||||||
Get the default filter presets.
|
Get the default filter presets.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary of default presets
|
Dictionary of default presets
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"All Entries": FilterOptions(),
|
"All Entries": FilterOptions(),
|
||||||
"Active Only": FilterOptions(
|
"Active Only": FilterOptions(show_inactive=False, active_only=True),
|
||||||
show_inactive=False,
|
"Inactive Only": FilterOptions(show_active=False, inactive_only=True),
|
||||||
active_only=True
|
"DNS Entries Only": FilterOptions(show_ip_entries=False, dns_only=True),
|
||||||
),
|
"IP Entries Only": FilterOptions(show_dns_entries=False, ip_only=True),
|
||||||
"Inactive Only": FilterOptions(
|
"DNS Mismatches": FilterOptions(mismatch_only=True),
|
||||||
show_active=False,
|
"Resolved Entries": FilterOptions(resolved_only=True),
|
||||||
inactive_only=True
|
|
||||||
),
|
|
||||||
"DNS Entries Only": FilterOptions(
|
|
||||||
show_ip_entries=False,
|
|
||||||
dns_only=True
|
|
||||||
),
|
|
||||||
"IP Entries Only": FilterOptions(
|
|
||||||
show_dns_entries=False,
|
|
||||||
ip_only=True
|
|
||||||
),
|
|
||||||
"DNS Mismatches": FilterOptions(
|
|
||||||
mismatch_only=True
|
|
||||||
),
|
|
||||||
"Resolved Entries": FilterOptions(
|
|
||||||
resolved_only=True
|
|
||||||
),
|
|
||||||
"Unresolved Entries": FilterOptions(
|
"Unresolved Entries": FilterOptions(
|
||||||
show_resolved=False,
|
show_resolved=False,
|
||||||
show_resolving=False,
|
show_resolving=False,
|
||||||
show_failed=False,
|
show_failed=False,
|
||||||
show_mismatched=False
|
show_mismatched=False,
|
||||||
)
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_saved_presets(self) -> Dict[str, FilterOptions]:
|
def get_saved_presets(self) -> Dict[str, FilterOptions]:
|
||||||
"""
|
"""
|
||||||
Get all saved presets (both default and custom).
|
Get all saved presets (both default and custom).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary of all presets
|
Dictionary of all presets
|
||||||
"""
|
"""
|
||||||
return self.presets.copy()
|
return self.presets.copy()
|
||||||
|
|
||||||
def count_filtered_entries(self, entries: List[HostEntry], options: FilterOptions) -> Dict[str, int]:
|
def count_filtered_entries(
|
||||||
|
self, entries: List[HostEntry], options: FilterOptions
|
||||||
|
) -> Dict[str, int]:
|
||||||
"""
|
"""
|
||||||
Count entries by category for the given filter options.
|
Count entries by category for the given filter options.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
entries: List of entries to analyze
|
entries: List of entries to analyze
|
||||||
options: Filter options to apply
|
options: Filter options to apply
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with count statistics
|
Dictionary with count statistics
|
||||||
"""
|
"""
|
||||||
filtered_entries = self.apply_filters(entries, options)
|
filtered_entries = self.apply_filters(entries, options)
|
||||||
total_entries = len(entries)
|
total_entries = len(entries)
|
||||||
filtered_count = len(filtered_entries)
|
filtered_count = len(filtered_entries)
|
||||||
|
|
||||||
# Count by status
|
# Count by status
|
||||||
active_count = len([e for e in filtered_entries if e.is_active])
|
active_count = len([e for e in filtered_entries if e.is_active])
|
||||||
inactive_count = filtered_count - active_count
|
inactive_count = filtered_count - active_count
|
||||||
|
|
||||||
# Count by type
|
# Count by type
|
||||||
dns_count = len([e for e in filtered_entries if e.has_dns_name()])
|
dns_count = len([e for e in filtered_entries if e.has_dns_name()])
|
||||||
ip_count = filtered_count - dns_count
|
ip_count = filtered_count - dns_count
|
||||||
|
|
||||||
# Count by resolution status
|
# Count by resolution status
|
||||||
resolved_count = len([e for e in filtered_entries
|
resolved_count = len(
|
||||||
if e.dns_resolution_status in ["IP_MATCH", "RESOLVED"]])
|
[
|
||||||
unresolved_count = len([e for e in filtered_entries
|
e
|
||||||
if e.dns_resolution_status in [None, "NOT_RESOLVED"]])
|
for e in filtered_entries
|
||||||
resolving_count = len([e for e in filtered_entries
|
if e.dns_resolution_status in ["IP_MATCH", "RESOLVED"]
|
||||||
if e.dns_resolution_status == "RESOLVING"])
|
]
|
||||||
failed_count = len([e for e in filtered_entries
|
)
|
||||||
if e.dns_resolution_status == "RESOLUTION_FAILED"])
|
unresolved_count = len(
|
||||||
mismatch_count = len([e for e in filtered_entries
|
[
|
||||||
if e.dns_resolution_status == "IP_MISMATCH"])
|
e
|
||||||
|
for e in filtered_entries
|
||||||
|
if e.dns_resolution_status in [None, "NOT_RESOLVED"]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
resolving_count = len(
|
||||||
|
[e for e in filtered_entries if e.dns_resolution_status == "RESOLVING"]
|
||||||
|
)
|
||||||
|
failed_count = len(
|
||||||
|
[
|
||||||
|
e
|
||||||
|
for e in filtered_entries
|
||||||
|
if e.dns_resolution_status == "RESOLUTION_FAILED"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
mismatch_count = len(
|
||||||
|
[e for e in filtered_entries if e.dns_resolution_status == "IP_MISMATCH"]
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": total_entries,
|
"total": total_entries,
|
||||||
"filtered": filtered_count,
|
"filtered": filtered_count,
|
||||||
|
|
@ -501,5 +534,5 @@ class EntryFilter:
|
||||||
"unresolved": unresolved_count,
|
"unresolved": unresolved_count,
|
||||||
"resolving": resolving_count,
|
"resolving": resolving_count,
|
||||||
"failed": failed_count,
|
"failed": failed_count,
|
||||||
"mismatched": mismatch_count
|
"mismatched": mismatch_count,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,85 +15,102 @@ from datetime import datetime
|
||||||
|
|
||||||
from .models import HostEntry, HostsFile
|
from .models import HostEntry, HostsFile
|
||||||
|
|
||||||
|
|
||||||
class ExportFormat(Enum):
|
class ExportFormat(Enum):
|
||||||
"""Supported export formats."""
|
"""Supported export formats."""
|
||||||
|
|
||||||
HOSTS = "hosts"
|
HOSTS = "hosts"
|
||||||
JSON = "json"
|
JSON = "json"
|
||||||
CSV = "csv"
|
CSV = "csv"
|
||||||
|
|
||||||
|
|
||||||
class ImportFormat(Enum):
|
class ImportFormat(Enum):
|
||||||
"""Supported import formats."""
|
"""Supported import formats."""
|
||||||
|
|
||||||
HOSTS = "hosts"
|
HOSTS = "hosts"
|
||||||
JSON = "json"
|
JSON = "json"
|
||||||
CSV = "csv"
|
CSV = "csv"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ImportResult:
|
class ImportResult:
|
||||||
"""Result of an import operation."""
|
"""Result of an import operation."""
|
||||||
|
|
||||||
success: bool
|
success: bool
|
||||||
entries: List[HostEntry]
|
entries: List[HostEntry]
|
||||||
errors: List[str]
|
errors: List[str]
|
||||||
warnings: List[str]
|
warnings: List[str]
|
||||||
total_processed: int
|
total_processed: int
|
||||||
successfully_imported: int
|
successfully_imported: int
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_errors(self) -> bool:
|
def has_errors(self) -> bool:
|
||||||
"""Check if import had any errors."""
|
"""Check if import had any errors."""
|
||||||
return len(self.errors) > 0
|
return len(self.errors) > 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_warnings(self) -> bool:
|
def has_warnings(self) -> bool:
|
||||||
"""Check if import had any warnings."""
|
"""Check if import had any warnings."""
|
||||||
return len(self.warnings) > 0
|
return len(self.warnings) > 0
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ExportResult:
|
class ExportResult:
|
||||||
"""Result of an export operation."""
|
"""Result of an export operation."""
|
||||||
|
|
||||||
success: bool
|
success: bool
|
||||||
file_path: Path
|
file_path: Path
|
||||||
entries_exported: int
|
entries_exported: int
|
||||||
errors: List[str]
|
errors: List[str]
|
||||||
format: ExportFormat
|
format: ExportFormat
|
||||||
|
|
||||||
|
|
||||||
class ImportExportService:
|
class ImportExportService:
|
||||||
"""Handle multiple file format operations for hosts entries."""
|
"""Handle multiple file format operations for hosts entries."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize the import/export service."""
|
"""Initialize the import/export service."""
|
||||||
self.supported_export_formats = [ExportFormat.HOSTS, ExportFormat.JSON, ExportFormat.CSV]
|
self.supported_export_formats = [
|
||||||
self.supported_import_formats = [ImportFormat.HOSTS, ImportFormat.JSON, ImportFormat.CSV]
|
ExportFormat.HOSTS,
|
||||||
|
ExportFormat.JSON,
|
||||||
|
ExportFormat.CSV,
|
||||||
|
]
|
||||||
|
self.supported_import_formats = [
|
||||||
|
ImportFormat.HOSTS,
|
||||||
|
ImportFormat.JSON,
|
||||||
|
ImportFormat.CSV,
|
||||||
|
]
|
||||||
|
|
||||||
# Export Methods
|
# Export Methods
|
||||||
|
|
||||||
def export_hosts_format(self, hosts_file: HostsFile, path: Path) -> ExportResult:
|
def export_hosts_format(self, hosts_file: HostsFile, path: Path) -> ExportResult:
|
||||||
"""
|
"""
|
||||||
Export hosts file to standard hosts format.
|
Export hosts file to standard hosts format.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
hosts_file: HostsFile instance to export
|
hosts_file: HostsFile instance to export
|
||||||
path: Path where to save the exported file
|
path: Path where to save the exported file
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ExportResult with operation details
|
ExportResult with operation details
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from .parser import HostsParser
|
from .parser import HostsParser
|
||||||
|
|
||||||
# Use the parser to serialize and write the hosts file
|
# Use the parser to serialize and write the hosts file
|
||||||
parser = HostsParser(str(path))
|
parser = HostsParser(str(path))
|
||||||
content = parser.serialize(hosts_file)
|
content = parser.serialize(hosts_file)
|
||||||
|
|
||||||
# Write the content to file
|
# Write the content to file
|
||||||
with open(path, 'w', encoding='utf-8') as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
|
|
||||||
return ExportResult(
|
return ExportResult(
|
||||||
success=True,
|
success=True,
|
||||||
file_path=path,
|
file_path=path,
|
||||||
entries_exported=len(hosts_file.entries),
|
entries_exported=len(hosts_file.entries),
|
||||||
errors=[],
|
errors=[],
|
||||||
format=ExportFormat.HOSTS
|
format=ExportFormat.HOSTS,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ExportResult(
|
return ExportResult(
|
||||||
|
|
@ -101,17 +118,17 @@ class ImportExportService:
|
||||||
file_path=path,
|
file_path=path,
|
||||||
entries_exported=0,
|
entries_exported=0,
|
||||||
errors=[f"Failed to export hosts format: {str(e)}"],
|
errors=[f"Failed to export hosts format: {str(e)}"],
|
||||||
format=ExportFormat.HOSTS
|
format=ExportFormat.HOSTS,
|
||||||
)
|
)
|
||||||
|
|
||||||
def export_json_format(self, hosts_file: HostsFile, path: Path) -> ExportResult:
|
def export_json_format(self, hosts_file: HostsFile, path: Path) -> ExportResult:
|
||||||
"""
|
"""
|
||||||
Export hosts file to JSON format with metadata.
|
Export hosts file to JSON format with metadata.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
hosts_file: HostsFile instance to export
|
hosts_file: HostsFile instance to export
|
||||||
path: Path where to save the exported file
|
path: Path where to save the exported file
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ExportResult with operation details
|
ExportResult with operation details
|
||||||
"""
|
"""
|
||||||
|
|
@ -121,19 +138,19 @@ class ImportExportService:
|
||||||
"exported_at": datetime.now().isoformat(),
|
"exported_at": datetime.now().isoformat(),
|
||||||
"total_entries": len(hosts_file.entries),
|
"total_entries": len(hosts_file.entries),
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"format": "hosts_json_export"
|
"format": "hosts_json_export",
|
||||||
},
|
},
|
||||||
"entries": []
|
"entries": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
for entry in hosts_file.entries:
|
for entry in hosts_file.entries:
|
||||||
entry_data = {
|
entry_data = {
|
||||||
"ip_address": entry.ip_address,
|
"ip_address": entry.ip_address,
|
||||||
"hostnames": entry.hostnames,
|
"hostnames": entry.hostnames,
|
||||||
"comment": entry.comment,
|
"comment": entry.comment,
|
||||||
"is_active": entry.is_active
|
"is_active": entry.is_active,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add DNS fields if present
|
# Add DNS fields if present
|
||||||
if entry.dns_name:
|
if entry.dns_name:
|
||||||
entry_data["dns_name"] = entry.dns_name
|
entry_data["dns_name"] = entry.dns_name
|
||||||
|
|
@ -143,107 +160,115 @@ class ImportExportService:
|
||||||
entry_data["last_resolved"] = entry.last_resolved.isoformat()
|
entry_data["last_resolved"] = entry.last_resolved.isoformat()
|
||||||
if entry.dns_resolution_status:
|
if entry.dns_resolution_status:
|
||||||
entry_data["dns_resolution_status"] = entry.dns_resolution_status
|
entry_data["dns_resolution_status"] = entry.dns_resolution_status
|
||||||
|
|
||||||
export_data["entries"].append(entry_data)
|
export_data["entries"].append(entry_data)
|
||||||
|
|
||||||
with open(path, 'w', encoding='utf-8') as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
json.dump(export_data, f, indent=2, ensure_ascii=False)
|
json.dump(export_data, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
return ExportResult(
|
return ExportResult(
|
||||||
success=True,
|
success=True,
|
||||||
file_path=path,
|
file_path=path,
|
||||||
entries_exported=len(hosts_file.entries),
|
entries_exported=len(hosts_file.entries),
|
||||||
errors=[],
|
errors=[],
|
||||||
format=ExportFormat.JSON
|
format=ExportFormat.JSON,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ExportResult(
|
return ExportResult(
|
||||||
success=False,
|
success=False,
|
||||||
file_path=path,
|
file_path=path,
|
||||||
entries_exported=0,
|
entries_exported=0,
|
||||||
errors=[f"Failed to export JSON format: {str(e)}"],
|
errors=[f"Failed to export JSON format: {str(e)}"],
|
||||||
format=ExportFormat.JSON
|
format=ExportFormat.JSON,
|
||||||
)
|
)
|
||||||
|
|
||||||
def export_csv_format(self, hosts_file: HostsFile, path: Path) -> ExportResult:
|
def export_csv_format(self, hosts_file: HostsFile, path: Path) -> ExportResult:
|
||||||
"""
|
"""
|
||||||
Export hosts file to CSV format.
|
Export hosts file to CSV format.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
hosts_file: HostsFile instance to export
|
hosts_file: HostsFile instance to export
|
||||||
path: Path where to save the exported file
|
path: Path where to save the exported file
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ExportResult with operation details
|
ExportResult with operation details
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
fieldnames = [
|
fieldnames = [
|
||||||
'ip_address', 'hostnames', 'comment', 'is_active',
|
"ip_address",
|
||||||
'dns_name', 'resolved_ip', 'last_resolved', 'dns_resolution_status'
|
"hostnames",
|
||||||
|
"comment",
|
||||||
|
"is_active",
|
||||||
|
"dns_name",
|
||||||
|
"resolved_ip",
|
||||||
|
"last_resolved",
|
||||||
|
"dns_resolution_status",
|
||||||
]
|
]
|
||||||
|
|
||||||
with open(path, 'w', newline='', encoding='utf-8') as csvfile:
|
with open(path, "w", newline="", encoding="utf-8") as csvfile:
|
||||||
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
|
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
|
||||||
writer.writeheader()
|
writer.writeheader()
|
||||||
|
|
||||||
for entry in hosts_file.entries:
|
for entry in hosts_file.entries:
|
||||||
row_data = {
|
row_data = {
|
||||||
'ip_address': entry.ip_address,
|
"ip_address": entry.ip_address,
|
||||||
'hostnames': ' '.join(entry.hostnames),
|
"hostnames": " ".join(entry.hostnames),
|
||||||
'comment': entry.comment or '',
|
"comment": entry.comment or "",
|
||||||
'is_active': entry.is_active,
|
"is_active": entry.is_active,
|
||||||
'dns_name': entry.dns_name or '',
|
"dns_name": entry.dns_name or "",
|
||||||
'resolved_ip': entry.resolved_ip or '',
|
"resolved_ip": entry.resolved_ip or "",
|
||||||
'last_resolved': entry.last_resolved.isoformat() if entry.last_resolved else '',
|
"last_resolved": entry.last_resolved.isoformat()
|
||||||
'dns_resolution_status': entry.dns_resolution_status or ''
|
if entry.last_resolved
|
||||||
|
else "",
|
||||||
|
"dns_resolution_status": entry.dns_resolution_status or "",
|
||||||
}
|
}
|
||||||
writer.writerow(row_data)
|
writer.writerow(row_data)
|
||||||
|
|
||||||
return ExportResult(
|
return ExportResult(
|
||||||
success=True,
|
success=True,
|
||||||
file_path=path,
|
file_path=path,
|
||||||
entries_exported=len(hosts_file.entries),
|
entries_exported=len(hosts_file.entries),
|
||||||
errors=[],
|
errors=[],
|
||||||
format=ExportFormat.CSV
|
format=ExportFormat.CSV,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ExportResult(
|
return ExportResult(
|
||||||
success=False,
|
success=False,
|
||||||
file_path=path,
|
file_path=path,
|
||||||
entries_exported=0,
|
entries_exported=0,
|
||||||
errors=[f"Failed to export CSV format: {str(e)}"],
|
errors=[f"Failed to export CSV format: {str(e)}"],
|
||||||
format=ExportFormat.CSV
|
format=ExportFormat.CSV,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Import Methods
|
# Import Methods
|
||||||
|
|
||||||
def import_hosts_format(self, path: Path) -> ImportResult:
|
def import_hosts_format(self, path: Path) -> ImportResult:
|
||||||
"""
|
"""
|
||||||
Import from hosts file format.
|
Import from hosts file format.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
path: Path to the hosts file to import
|
path: Path to the hosts file to import
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ImportResult with imported entries and any errors
|
ImportResult with imported entries and any errors
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from .parser import HostsParser
|
from .parser import HostsParser
|
||||||
|
|
||||||
parser = HostsParser(str(path))
|
parser = HostsParser(str(path))
|
||||||
hosts_file = parser.parse()
|
hosts_file = parser.parse()
|
||||||
|
|
||||||
return ImportResult(
|
return ImportResult(
|
||||||
success=True,
|
success=True,
|
||||||
entries=hosts_file.entries,
|
entries=hosts_file.entries,
|
||||||
errors=[],
|
errors=[],
|
||||||
warnings=[],
|
warnings=[],
|
||||||
total_processed=len(hosts_file.entries),
|
total_processed=len(hosts_file.entries),
|
||||||
successfully_imported=len(hosts_file.entries)
|
successfully_imported=len(hosts_file.entries),
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ImportResult(
|
return ImportResult(
|
||||||
success=False,
|
success=False,
|
||||||
|
|
@ -251,61 +276,61 @@ class ImportExportService:
|
||||||
errors=[f"Failed to import hosts format: {str(e)}"],
|
errors=[f"Failed to import hosts format: {str(e)}"],
|
||||||
warnings=[],
|
warnings=[],
|
||||||
total_processed=0,
|
total_processed=0,
|
||||||
successfully_imported=0
|
successfully_imported=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
def import_json_format(self, path: Path) -> ImportResult:
|
def import_json_format(self, path: Path) -> ImportResult:
|
||||||
"""
|
"""
|
||||||
Import from JSON format with validation.
|
Import from JSON format with validation.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
path: Path to the JSON file to import
|
path: Path to the JSON file to import
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ImportResult with imported entries and any errors
|
ImportResult with imported entries and any errors
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with open(path, 'r', encoding='utf-8') as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
|
|
||||||
if not isinstance(data, dict) or 'entries' not in data:
|
if not isinstance(data, dict) or "entries" not in data:
|
||||||
return ImportResult(
|
return ImportResult(
|
||||||
success=False,
|
success=False,
|
||||||
entries=[],
|
entries=[],
|
||||||
errors=["Invalid JSON format: missing 'entries' field"],
|
errors=["Invalid JSON format: missing 'entries' field"],
|
||||||
warnings=[],
|
warnings=[],
|
||||||
total_processed=0,
|
total_processed=0,
|
||||||
successfully_imported=0
|
successfully_imported=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
entries = []
|
entries = []
|
||||||
errors = []
|
errors = []
|
||||||
warnings = []
|
warnings = []
|
||||||
total_processed = len(data['entries'])
|
total_processed = len(data["entries"])
|
||||||
|
|
||||||
for i, entry_data in enumerate(data['entries']):
|
for i, entry_data in enumerate(data["entries"]):
|
||||||
try:
|
try:
|
||||||
# Validate required fields
|
# Validate required fields
|
||||||
if not isinstance(entry_data, dict):
|
if not isinstance(entry_data, dict):
|
||||||
errors.append(f"Entry {i+1}: Invalid entry format")
|
errors.append(f"Entry {i + 1}: Invalid entry format")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if 'hostnames' not in entry_data or not entry_data['hostnames']:
|
if "hostnames" not in entry_data or not entry_data["hostnames"]:
|
||||||
errors.append(f"Entry {i+1}: Missing hostnames field")
|
errors.append(f"Entry {i + 1}: Missing hostnames field")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Handle DNS vs IP entries
|
# Handle DNS vs IP entries
|
||||||
dns_name = entry_data.get('dns_name', '')
|
dns_name = entry_data.get("dns_name", "")
|
||||||
ip_address = entry_data.get('ip_address', '')
|
ip_address = entry_data.get("ip_address", "")
|
||||||
|
|
||||||
# Create entry with temporary IP if it's a DNS-only entry
|
# Create entry with temporary IP if it's a DNS-only entry
|
||||||
if dns_name and not ip_address:
|
if dns_name and not ip_address:
|
||||||
# Create with temporary IP, then convert to DNS entry
|
# Create with temporary IP, then convert to DNS entry
|
||||||
entry = HostEntry(
|
entry = HostEntry(
|
||||||
ip_address="127.0.0.1", # Temporary IP
|
ip_address="127.0.0.1", # Temporary IP
|
||||||
hostnames=entry_data['hostnames'],
|
hostnames=entry_data["hostnames"],
|
||||||
comment=entry_data.get('comment', ''),
|
comment=entry_data.get("comment", ""),
|
||||||
is_active=entry_data.get('is_active', True)
|
is_active=entry_data.get("is_active", True),
|
||||||
)
|
)
|
||||||
# Convert to DNS entry
|
# Convert to DNS entry
|
||||||
entry.ip_address = ""
|
entry.ip_address = ""
|
||||||
|
|
@ -314,39 +339,45 @@ class ImportExportService:
|
||||||
# Regular IP entry
|
# Regular IP entry
|
||||||
entry = HostEntry(
|
entry = HostEntry(
|
||||||
ip_address=ip_address,
|
ip_address=ip_address,
|
||||||
hostnames=entry_data['hostnames'],
|
hostnames=entry_data["hostnames"],
|
||||||
comment=entry_data.get('comment', ''),
|
comment=entry_data.get("comment", ""),
|
||||||
is_active=entry_data.get('is_active', True)
|
is_active=entry_data.get("is_active", True),
|
||||||
)
|
)
|
||||||
# Set DNS name if present for IP entries
|
# Set DNS name if present for IP entries
|
||||||
if dns_name:
|
if dns_name:
|
||||||
entry.dns_name = dns_name
|
entry.dns_name = dns_name
|
||||||
if 'resolved_ip' in entry_data:
|
if "resolved_ip" in entry_data:
|
||||||
entry.resolved_ip = entry_data['resolved_ip']
|
entry.resolved_ip = entry_data["resolved_ip"]
|
||||||
if 'last_resolved' in entry_data and entry_data['last_resolved']:
|
if "last_resolved" in entry_data and entry_data["last_resolved"]:
|
||||||
try:
|
try:
|
||||||
entry.last_resolved = datetime.fromisoformat(entry_data['last_resolved'])
|
entry.last_resolved = datetime.fromisoformat(
|
||||||
|
entry_data["last_resolved"]
|
||||||
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
warnings.append(f"Entry {i+1}: Invalid last_resolved date format")
|
warnings.append(
|
||||||
if 'dns_resolution_status' in entry_data:
|
f"Entry {i + 1}: Invalid last_resolved date format"
|
||||||
entry.dns_resolution_status = entry_data['dns_resolution_status']
|
)
|
||||||
|
if "dns_resolution_status" in entry_data:
|
||||||
|
entry.dns_resolution_status = entry_data[
|
||||||
|
"dns_resolution_status"
|
||||||
|
]
|
||||||
|
|
||||||
entries.append(entry)
|
entries.append(entry)
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
errors.append(f"Entry {i+1}: {str(e)}")
|
errors.append(f"Entry {i + 1}: {str(e)}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append(f"Entry {i+1}: Unexpected error - {str(e)}")
|
errors.append(f"Entry {i + 1}: Unexpected error - {str(e)}")
|
||||||
|
|
||||||
return ImportResult(
|
return ImportResult(
|
||||||
success=len(errors) == 0,
|
success=len(errors) == 0,
|
||||||
entries=entries,
|
entries=entries,
|
||||||
errors=errors,
|
errors=errors,
|
||||||
warnings=warnings,
|
warnings=warnings,
|
||||||
total_processed=total_processed,
|
total_processed=total_processed,
|
||||||
successfully_imported=len(entries)
|
successfully_imported=len(entries),
|
||||||
)
|
)
|
||||||
|
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
return ImportResult(
|
return ImportResult(
|
||||||
success=False,
|
success=False,
|
||||||
|
|
@ -354,7 +385,7 @@ class ImportExportService:
|
||||||
errors=[f"Invalid JSON file: {str(e)}"],
|
errors=[f"Invalid JSON file: {str(e)}"],
|
||||||
warnings=[],
|
warnings=[],
|
||||||
total_processed=0,
|
total_processed=0,
|
||||||
successfully_imported=0
|
successfully_imported=0,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ImportResult(
|
return ImportResult(
|
||||||
|
|
@ -363,16 +394,16 @@ class ImportExportService:
|
||||||
errors=[f"Failed to import JSON format: {str(e)}"],
|
errors=[f"Failed to import JSON format: {str(e)}"],
|
||||||
warnings=[],
|
warnings=[],
|
||||||
total_processed=0,
|
total_processed=0,
|
||||||
successfully_imported=0
|
successfully_imported=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
def import_csv_format(self, path: Path) -> ImportResult:
|
def import_csv_format(self, path: Path) -> ImportResult:
|
||||||
"""
|
"""
|
||||||
Import from CSV format with field mapping.
|
Import from CSV format with field mapping.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
path: Path to the CSV file to import
|
path: Path to the CSV file to import
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ImportResult with imported entries and any errors
|
ImportResult with imported entries and any errors
|
||||||
"""
|
"""
|
||||||
|
|
@ -381,18 +412,20 @@ class ImportExportService:
|
||||||
errors = []
|
errors = []
|
||||||
warnings = []
|
warnings = []
|
||||||
total_processed = 0
|
total_processed = 0
|
||||||
|
|
||||||
with open(path, 'r', encoding='utf-8') as csvfile:
|
with open(path, "r", encoding="utf-8") as csvfile:
|
||||||
# Try to detect the dialect
|
# Try to detect the dialect
|
||||||
sample = csvfile.read(1024)
|
sample = csvfile.read(1024)
|
||||||
csvfile.seek(0)
|
csvfile.seek(0)
|
||||||
dialect = csv.Sniffer().sniff(sample)
|
dialect = csv.Sniffer().sniff(sample)
|
||||||
|
|
||||||
reader = csv.DictReader(csvfile, dialect=dialect)
|
reader = csv.DictReader(csvfile, dialect=dialect)
|
||||||
|
|
||||||
# Validate required columns
|
# Validate required columns
|
||||||
required_columns = ['hostnames']
|
required_columns = ["hostnames"]
|
||||||
missing_columns = [col for col in required_columns if col not in reader.fieldnames]
|
missing_columns = [
|
||||||
|
col for col in required_columns if col not in reader.fieldnames
|
||||||
|
]
|
||||||
if missing_columns:
|
if missing_columns:
|
||||||
return ImportResult(
|
return ImportResult(
|
||||||
success=False,
|
success=False,
|
||||||
|
|
@ -400,39 +433,39 @@ class ImportExportService:
|
||||||
errors=[f"Missing required columns: {missing_columns}"],
|
errors=[f"Missing required columns: {missing_columns}"],
|
||||||
warnings=[],
|
warnings=[],
|
||||||
total_processed=0,
|
total_processed=0,
|
||||||
successfully_imported=0
|
successfully_imported=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
for row_num, row in enumerate(reader, start=2): # Start at 2 for header
|
for row_num, row in enumerate(reader, start=2): # Start at 2 for header
|
||||||
total_processed += 1
|
total_processed += 1
|
||||||
try:
|
try:
|
||||||
# Parse hostnames
|
# Parse hostnames
|
||||||
hostnames_str = row.get('hostnames', '').strip()
|
hostnames_str = row.get("hostnames", "").strip()
|
||||||
if not hostnames_str:
|
if not hostnames_str:
|
||||||
errors.append(f"Row {row_num}: Empty hostnames field")
|
errors.append(f"Row {row_num}: Empty hostnames field")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
hostnames = [h.strip() for h in hostnames_str.split()]
|
hostnames = [h.strip() for h in hostnames_str.split()]
|
||||||
if not hostnames:
|
if not hostnames:
|
||||||
errors.append(f"Row {row_num}: No valid hostnames found")
|
errors.append(f"Row {row_num}: No valid hostnames found")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Parse is_active
|
# Parse is_active
|
||||||
is_active_str = row.get('is_active', 'true').lower()
|
is_active_str = row.get("is_active", "true").lower()
|
||||||
is_active = is_active_str in ('true', '1', 'yes', 'active')
|
is_active = is_active_str in ("true", "1", "yes", "active")
|
||||||
|
|
||||||
# Handle DNS vs IP entries
|
# Handle DNS vs IP entries
|
||||||
dns_name = row.get('dns_name', '').strip()
|
dns_name = row.get("dns_name", "").strip()
|
||||||
ip_address = row.get('ip_address', '').strip()
|
ip_address = row.get("ip_address", "").strip()
|
||||||
|
|
||||||
# Create entry with temporary IP if it's a DNS-only entry
|
# Create entry with temporary IP if it's a DNS-only entry
|
||||||
if dns_name and not ip_address:
|
if dns_name and not ip_address:
|
||||||
# Create with temporary IP, then convert to DNS entry
|
# Create with temporary IP, then convert to DNS entry
|
||||||
entry = HostEntry(
|
entry = HostEntry(
|
||||||
ip_address="127.0.0.1", # Temporary IP
|
ip_address="127.0.0.1", # Temporary IP
|
||||||
hostnames=hostnames,
|
hostnames=hostnames,
|
||||||
comment=row.get('comment', '').strip(),
|
comment=row.get("comment", "").strip(),
|
||||||
is_active=is_active
|
is_active=is_active,
|
||||||
)
|
)
|
||||||
# Convert to DNS entry
|
# Convert to DNS entry
|
||||||
entry.ip_address = ""
|
entry.ip_address = ""
|
||||||
|
|
@ -442,38 +475,44 @@ class ImportExportService:
|
||||||
entry = HostEntry(
|
entry = HostEntry(
|
||||||
ip_address=ip_address,
|
ip_address=ip_address,
|
||||||
hostnames=hostnames,
|
hostnames=hostnames,
|
||||||
comment=row.get('comment', '').strip(),
|
comment=row.get("comment", "").strip(),
|
||||||
is_active=is_active
|
is_active=is_active,
|
||||||
)
|
)
|
||||||
# Set DNS name if present for IP entries
|
# Set DNS name if present for IP entries
|
||||||
if dns_name:
|
if dns_name:
|
||||||
entry.dns_name = dns_name
|
entry.dns_name = dns_name
|
||||||
if row.get('resolved_ip', '').strip():
|
if row.get("resolved_ip", "").strip():
|
||||||
entry.resolved_ip = row['resolved_ip'].strip()
|
entry.resolved_ip = row["resolved_ip"].strip()
|
||||||
if row.get('last_resolved', '').strip():
|
if row.get("last_resolved", "").strip():
|
||||||
try:
|
try:
|
||||||
entry.last_resolved = datetime.fromisoformat(row['last_resolved'].strip())
|
entry.last_resolved = datetime.fromisoformat(
|
||||||
|
row["last_resolved"].strip()
|
||||||
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
warnings.append(f"Row {row_num}: Invalid last_resolved date format")
|
warnings.append(
|
||||||
if row.get('dns_resolution_status', '').strip():
|
f"Row {row_num}: Invalid last_resolved date format"
|
||||||
entry.dns_resolution_status = row['dns_resolution_status'].strip()
|
)
|
||||||
|
if row.get("dns_resolution_status", "").strip():
|
||||||
|
entry.dns_resolution_status = row[
|
||||||
|
"dns_resolution_status"
|
||||||
|
].strip()
|
||||||
|
|
||||||
entries.append(entry)
|
entries.append(entry)
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
errors.append(f"Row {row_num}: {str(e)}")
|
errors.append(f"Row {row_num}: {str(e)}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append(f"Row {row_num}: Unexpected error - {str(e)}")
|
errors.append(f"Row {row_num}: Unexpected error - {str(e)}")
|
||||||
|
|
||||||
return ImportResult(
|
return ImportResult(
|
||||||
success=len(errors) == 0,
|
success=len(errors) == 0,
|
||||||
entries=entries,
|
entries=entries,
|
||||||
errors=errors,
|
errors=errors,
|
||||||
warnings=warnings,
|
warnings=warnings,
|
||||||
total_processed=total_processed,
|
total_processed=total_processed,
|
||||||
successfully_imported=len(entries)
|
successfully_imported=len(entries),
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ImportResult(
|
return ImportResult(
|
||||||
success=False,
|
success=False,
|
||||||
|
|
@ -481,99 +520,103 @@ class ImportExportService:
|
||||||
errors=[f"Failed to import CSV format: {str(e)}"],
|
errors=[f"Failed to import CSV format: {str(e)}"],
|
||||||
warnings=[],
|
warnings=[],
|
||||||
total_processed=0,
|
total_processed=0,
|
||||||
successfully_imported=0
|
successfully_imported=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Utility Methods
|
# Utility Methods
|
||||||
|
|
||||||
def detect_file_format(self, path: Path) -> Optional[ImportFormat]:
|
def detect_file_format(self, path: Path) -> Optional[ImportFormat]:
|
||||||
"""
|
"""
|
||||||
Detect the format of a file based on extension and content.
|
Detect the format of a file based on extension and content.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
path: Path to the file to analyze
|
path: Path to the file to analyze
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Detected ImportFormat or None if unknown
|
Detected ImportFormat or None if unknown
|
||||||
"""
|
"""
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Check by extension first
|
# Check by extension first
|
||||||
extension = path.suffix.lower()
|
extension = path.suffix.lower()
|
||||||
if extension == '.json':
|
if extension == ".json":
|
||||||
return ImportFormat.JSON
|
return ImportFormat.JSON
|
||||||
elif extension == '.csv':
|
elif extension == ".csv":
|
||||||
return ImportFormat.CSV
|
return ImportFormat.CSV
|
||||||
elif path.name in ['hosts', '/etc/hosts'] or extension in ['.hosts', '.txt']:
|
elif path.name in ["hosts", "/etc/hosts"] or extension in [".hosts", ".txt"]:
|
||||||
return ImportFormat.HOSTS
|
return ImportFormat.HOSTS
|
||||||
|
|
||||||
# Try to detect by content
|
# Try to detect by content
|
||||||
try:
|
try:
|
||||||
with open(path, 'r', encoding='utf-8') as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
first_line = f.readline().strip()
|
first_line = f.readline().strip()
|
||||||
|
|
||||||
# Check for JSON
|
# Check for JSON
|
||||||
if first_line.startswith('{'):
|
if first_line.startswith("{"):
|
||||||
return ImportFormat.JSON
|
return ImportFormat.JSON
|
||||||
|
|
||||||
# Check for CSV (look for comma separators)
|
# Check for CSV (look for comma separators)
|
||||||
if ',' in first_line and not first_line.startswith('#'):
|
if "," in first_line and not first_line.startswith("#"):
|
||||||
return ImportFormat.CSV
|
return ImportFormat.CSV
|
||||||
|
|
||||||
# Default to hosts format
|
# Default to hosts format
|
||||||
return ImportFormat.HOSTS
|
return ImportFormat.HOSTS
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def validate_export_path(self, path: Path, format: ExportFormat) -> List[str]:
|
def validate_export_path(self, path: Path, format: ExportFormat) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Validate export path and return any warnings.
|
Validate export path and return any warnings.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
path: Target export path
|
path: Target export path
|
||||||
format: Export format
|
format: Export format
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of validation warnings
|
List of validation warnings
|
||||||
"""
|
"""
|
||||||
warnings = []
|
warnings = []
|
||||||
|
|
||||||
# Check if file already exists
|
# Check if file already exists
|
||||||
if path.exists():
|
if path.exists():
|
||||||
warnings.append(f"File {path} already exists and will be overwritten")
|
warnings.append(f"File {path} already exists and will be overwritten")
|
||||||
|
|
||||||
# Check if directory exists
|
# Check if directory exists
|
||||||
if not path.parent.exists():
|
if not path.parent.exists():
|
||||||
warnings.append(f"Directory {path.parent} does not exist")
|
warnings.append(f"Directory {path.parent} does not exist")
|
||||||
|
|
||||||
# Check write permissions
|
# Check write permissions
|
||||||
try:
|
try:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
test_file = path.parent / '.write_test'
|
test_file = path.parent / ".write_test"
|
||||||
test_file.touch()
|
test_file.touch()
|
||||||
test_file.unlink()
|
test_file.unlink()
|
||||||
except Exception:
|
except Exception:
|
||||||
warnings.append(f"No write permission for directory {path.parent}")
|
warnings.append(f"No write permission for directory {path.parent}")
|
||||||
|
|
||||||
# Check extension matches format
|
# Check extension matches format
|
||||||
expected_extensions = {
|
expected_extensions = {
|
||||||
ExportFormat.HOSTS: ['.hosts', '.txt', ''],
|
ExportFormat.HOSTS: [".hosts", ".txt", ""],
|
||||||
ExportFormat.JSON: ['.json'],
|
ExportFormat.JSON: [".json"],
|
||||||
ExportFormat.CSV: ['.csv']
|
ExportFormat.CSV: [".csv"],
|
||||||
}
|
}
|
||||||
|
|
||||||
if path.suffix.lower() not in expected_extensions[format]:
|
if path.suffix.lower() not in expected_extensions[format]:
|
||||||
suggested_ext = expected_extensions[format][0] if expected_extensions[format] else ''
|
suggested_ext = (
|
||||||
warnings.append(f"File extension '{path.suffix}' doesn't match format {format.value}{f', suggest {suggested_ext}' if suggested_ext else ''}")
|
expected_extensions[format][0] if expected_extensions[format] else ""
|
||||||
|
)
|
||||||
|
warnings.append(
|
||||||
|
f"File extension '{path.suffix}' doesn't match format {format.value}{f', suggest {suggested_ext}' if suggested_ext else ''}"
|
||||||
|
)
|
||||||
|
|
||||||
return warnings
|
return warnings
|
||||||
|
|
||||||
def get_supported_export_formats(self) -> List[ExportFormat]:
|
def get_supported_export_formats(self) -> List[ExportFormat]:
|
||||||
"""Get list of supported export formats."""
|
"""Get list of supported export formats."""
|
||||||
return self.supported_export_formats.copy()
|
return self.supported_export_formats.copy()
|
||||||
|
|
||||||
def get_supported_import_formats(self) -> List[ImportFormat]:
|
def get_supported_import_formats(self) -> List[ImportFormat]:
|
||||||
"""Get list of supported import formats."""
|
"""Get list of supported import formats."""
|
||||||
return self.supported_import_formats.copy()
|
return self.supported_import_formats.copy()
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ from .commands import (
|
||||||
OperationResult,
|
OperationResult,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class PermissionManager:
|
class PermissionManager:
|
||||||
"""
|
"""
|
||||||
Manages sudo permissions for hosts file editing.
|
Manages sudo permissions for hosts file editing.
|
||||||
|
|
@ -120,6 +121,7 @@ class PermissionManager:
|
||||||
self.has_sudo = False
|
self.has_sudo = False
|
||||||
self._sudo_validated = False
|
self._sudo_validated = False
|
||||||
|
|
||||||
|
|
||||||
class HostsManager:
|
class HostsManager:
|
||||||
"""
|
"""
|
||||||
Main manager for hosts file edit operations.
|
Main manager for hosts file edit operations.
|
||||||
|
|
@ -422,7 +424,9 @@ class HostsManager:
|
||||||
return False, f"Error updating entry: {e}"
|
return False, f"Error updating entry: {e}"
|
||||||
|
|
||||||
# Command-based methods for undo/redo functionality
|
# Command-based methods for undo/redo functionality
|
||||||
def execute_toggle_command(self, hosts_file: HostsFile, index: int) -> OperationResult:
|
def execute_toggle_command(
|
||||||
|
self, hosts_file: HostsFile, index: int
|
||||||
|
) -> OperationResult:
|
||||||
"""
|
"""
|
||||||
Execute a toggle command with undo/redo support.
|
Execute a toggle command with undo/redo support.
|
||||||
|
|
||||||
|
|
@ -440,7 +444,9 @@ class HostsManager:
|
||||||
result = self.undo_redo_history.execute_command(command, hosts_file)
|
result = self.undo_redo_history.execute_command(command, hosts_file)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def execute_move_command(self, hosts_file: HostsFile, index: int, direction: str) -> OperationResult:
|
def execute_move_command(
|
||||||
|
self, hosts_file: HostsFile, index: int, direction: str
|
||||||
|
) -> OperationResult:
|
||||||
"""
|
"""
|
||||||
Execute a move command with undo/redo support.
|
Execute a move command with undo/redo support.
|
||||||
|
|
||||||
|
|
@ -471,7 +477,9 @@ class HostsManager:
|
||||||
result = self.undo_redo_history.execute_command(command, hosts_file)
|
result = self.undo_redo_history.execute_command(command, hosts_file)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def execute_add_command(self, hosts_file: HostsFile, entry: HostEntry, save_callback=None) -> OperationResult:
|
def execute_add_command(
|
||||||
|
self, hosts_file: HostsFile, entry: HostEntry, save_callback=None
|
||||||
|
) -> OperationResult:
|
||||||
"""
|
"""
|
||||||
Execute an add command with undo/redo support.
|
Execute an add command with undo/redo support.
|
||||||
|
|
||||||
|
|
@ -490,7 +498,9 @@ class HostsManager:
|
||||||
result = self.undo_redo_history.execute_command(command, hosts_file)
|
result = self.undo_redo_history.execute_command(command, hosts_file)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def execute_delete_command(self, hosts_file: HostsFile, index: int, save_callback=None) -> OperationResult:
|
def execute_delete_command(
|
||||||
|
self, hosts_file: HostsFile, index: int, save_callback=None
|
||||||
|
) -> OperationResult:
|
||||||
"""
|
"""
|
||||||
Execute a delete command with undo/redo support.
|
Execute a delete command with undo/redo support.
|
||||||
|
|
||||||
|
|
@ -509,7 +519,15 @@ class HostsManager:
|
||||||
result = self.undo_redo_history.execute_command(command, hosts_file)
|
result = self.undo_redo_history.execute_command(command, hosts_file)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def execute_update_command(self, hosts_file: HostsFile, index: int, ip_address: str, hostnames: list[str], comment: Optional[str], is_active: bool) -> OperationResult:
|
def execute_update_command(
|
||||||
|
self,
|
||||||
|
hosts_file: HostsFile,
|
||||||
|
index: int,
|
||||||
|
ip_address: str,
|
||||||
|
hostnames: list[str],
|
||||||
|
comment: Optional[str],
|
||||||
|
is_active: bool,
|
||||||
|
) -> OperationResult:
|
||||||
"""
|
"""
|
||||||
Execute an update command with undo/redo support.
|
Execute an update command with undo/redo support.
|
||||||
|
|
||||||
|
|
@ -686,16 +704,19 @@ class HostsManager:
|
||||||
["sudo", "chmod", "644", str(self._backup_path)], capture_output=True
|
["sudo", "chmod", "644", str(self._backup_path)], capture_output=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class EditModeError(Exception):
|
class EditModeError(Exception):
|
||||||
"""Base exception for edit mode errors."""
|
"""Base exception for edit mode errors."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PermissionError(EditModeError):
|
class PermissionError(EditModeError):
|
||||||
"""Raised when there are permission issues."""
|
"""Raised when there are permission issues."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ValidationError(EditModeError):
|
class ValidationError(EditModeError):
|
||||||
"""Raised when validation fails."""
|
"""Raised when validation fails."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,9 @@ class HostEntry:
|
||||||
try:
|
try:
|
||||||
ipaddress.ip_address(self.resolved_ip)
|
ipaddress.ip_address(self.resolved_ip)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise ValueError(f"Invalid resolved IP address '{self.resolved_ip}': {e}")
|
raise ValueError(
|
||||||
|
f"Invalid resolved IP address '{self.resolved_ip}': {e}"
|
||||||
|
)
|
||||||
|
|
||||||
def to_hosts_line(self, ip_width: int = 0, hostname_width: int = 0) -> str:
|
def to_hosts_line(self, ip_width: int = 0, hostname_width: int = 0) -> str:
|
||||||
"""
|
"""
|
||||||
|
|
@ -168,7 +170,7 @@ class HostEntry:
|
||||||
|
|
||||||
# Build comment section (DNS metadata + user comment)
|
# Build comment section (DNS metadata + user comment)
|
||||||
comment_parts = []
|
comment_parts = []
|
||||||
|
|
||||||
# Add DNS metadata if present
|
# Add DNS metadata if present
|
||||||
if self.has_dns_name():
|
if self.has_dns_name():
|
||||||
dns_meta = f"DNS:{self.dns_name}"
|
dns_meta = f"DNS:{self.dns_name}"
|
||||||
|
|
@ -177,11 +179,11 @@ class HostEntry:
|
||||||
if self.last_resolved:
|
if self.last_resolved:
|
||||||
dns_meta += f"|Last:{self.last_resolved.isoformat()}"
|
dns_meta += f"|Last:{self.last_resolved.isoformat()}"
|
||||||
comment_parts.append(dns_meta)
|
comment_parts.append(dns_meta)
|
||||||
|
|
||||||
# Add user comment if present
|
# Add user comment if present
|
||||||
if self.comment:
|
if self.comment:
|
||||||
comment_parts.append(self.comment)
|
comment_parts.append(self.comment)
|
||||||
|
|
||||||
# Add complete comment section
|
# Add complete comment section
|
||||||
if comment_parts:
|
if comment_parts:
|
||||||
if len(self.hostnames) <= 1:
|
if len(self.hostnames) <= 1:
|
||||||
|
|
@ -269,21 +271,26 @@ class HostEntry:
|
||||||
|
|
||||||
if comment:
|
if comment:
|
||||||
# Split comment by pipe (|) to separate DNS metadata from user comment
|
# Split comment by pipe (|) to separate DNS metadata from user comment
|
||||||
comment_parts = [part.strip() for part in comment.split(' | ')]
|
comment_parts = [part.strip() for part in comment.split(" | ")]
|
||||||
|
|
||||||
for part in comment_parts:
|
for part in comment_parts:
|
||||||
if part.startswith('DNS:'):
|
if part.startswith("DNS:"):
|
||||||
# Parse DNS metadata: "DNS:example.com|Status:resolved|Last:2023-..."
|
# Parse DNS metadata: "DNS:example.com|Status:resolved|Last:2023-..."
|
||||||
dns_data = part.split('|')
|
dns_data = part.split("|")
|
||||||
for dns_part in dns_data:
|
for dns_part in dns_data:
|
||||||
if dns_part.startswith('DNS:'):
|
if dns_part.startswith("DNS:"):
|
||||||
dns_name = dns_part[4:] # Remove "DNS:" prefix
|
dns_name = dns_part[4:] # Remove "DNS:" prefix
|
||||||
elif dns_part.startswith('Status:'):
|
elif dns_part.startswith("Status:"):
|
||||||
dns_resolution_status = dns_part[7:] # Remove "Status:" prefix
|
dns_resolution_status = dns_part[
|
||||||
elif dns_part.startswith('Last:'):
|
7:
|
||||||
|
] # Remove "Status:" prefix
|
||||||
|
elif dns_part.startswith("Last:"):
|
||||||
try:
|
try:
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
last_resolved = datetime.fromisoformat(dns_part[5:]) # Remove "Last:" prefix
|
|
||||||
|
last_resolved = datetime.fromisoformat(
|
||||||
|
dns_part[5:]
|
||||||
|
) # Remove "Last:" prefix
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass # Invalid datetime format, ignore
|
pass # Invalid datetime format, ignore
|
||||||
else:
|
else:
|
||||||
|
|
@ -360,7 +367,11 @@ class HostsFile:
|
||||||
|
|
||||||
def get_stale_dns_entries(self, max_age_seconds: int = 300) -> List[HostEntry]:
|
def get_stale_dns_entries(self, max_age_seconds: int = 300) -> List[HostEntry]:
|
||||||
"""Get all entries with stale DNS resolution."""
|
"""Get all entries with stale DNS resolution."""
|
||||||
return [entry for entry in self.entries if entry.has_dns_name() and entry.is_dns_resolution_stale(max_age_seconds)]
|
return [
|
||||||
|
entry
|
||||||
|
for entry in self.entries
|
||||||
|
if entry.has_dns_name() and entry.is_dns_resolution_stale(max_age_seconds)
|
||||||
|
]
|
||||||
|
|
||||||
def sort_by_ip(self, ascending: bool = True) -> None:
|
def sort_by_ip(self, ascending: bool = True) -> None:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,9 @@ class AddEntryModal(ModalScreen):
|
||||||
with Vertical(classes="default-flex-section") as entry_type:
|
with Vertical(classes="default-flex-section") as entry_type:
|
||||||
entry_type.border_title = "Entry Type"
|
entry_type.border_title = "Entry Type"
|
||||||
with RadioSet(id="entry-type-radio", classes="default-radio-set"):
|
with RadioSet(id="entry-type-radio", classes="default-radio-set"):
|
||||||
yield RadioButton("IP Address Entry", value=True, id="ip-entry-radio")
|
yield RadioButton(
|
||||||
|
"IP Address Entry", value=True, id="ip-entry-radio"
|
||||||
|
)
|
||||||
yield RadioButton("DNS Name Entry", id="dns-entry-radio")
|
yield RadioButton("DNS Name Entry", id="dns-entry-radio")
|
||||||
|
|
||||||
# IP Address Section
|
# IP Address Section
|
||||||
|
|
@ -54,7 +56,9 @@ class AddEntryModal(ModalScreen):
|
||||||
yield Static("", id="ip-error", classes="validation-error")
|
yield Static("", id="ip-error", classes="validation-error")
|
||||||
|
|
||||||
# DNS Name Section (initially hidden)
|
# DNS Name Section (initially hidden)
|
||||||
with Vertical(classes="default-section hidden", id="dns-section") as dns_name:
|
with Vertical(
|
||||||
|
classes="default-section hidden", id="dns-section"
|
||||||
|
) as dns_name:
|
||||||
dns_name.border_title = "DNS Name (to resolve)"
|
dns_name.border_title = "DNS Name (to resolve)"
|
||||||
yield Input(
|
yield Input(
|
||||||
placeholder="e.g., example.com",
|
placeholder="e.g., example.com",
|
||||||
|
|
@ -119,17 +123,17 @@ class AddEntryModal(ModalScreen):
|
||||||
if pressed_radio and pressed_radio.id == "ip-entry-radio":
|
if pressed_radio and pressed_radio.id == "ip-entry-radio":
|
||||||
# Show IP section, hide DNS section
|
# Show IP section, hide DNS section
|
||||||
ip_section = self.query_one("#ip-section")
|
ip_section = self.query_one("#ip-section")
|
||||||
dns_section = self.query_one("#dns-section")
|
dns_section = self.query_one("#dns-section")
|
||||||
active_checkbox = self.query_one("#active-checkbox", Checkbox)
|
active_checkbox = self.query_one("#active-checkbox", Checkbox)
|
||||||
active_section = self.query_one("#active-checkbox").parent
|
active_section = self.query_one("#active-checkbox").parent
|
||||||
|
|
||||||
ip_section.remove_class("hidden")
|
ip_section.remove_class("hidden")
|
||||||
dns_section.add_class("hidden")
|
dns_section.add_class("hidden")
|
||||||
|
|
||||||
# Reset checkbox to default (active) for IP entries
|
# Reset checkbox to default (active) for IP entries
|
||||||
active_checkbox.value = True
|
active_checkbox.value = True
|
||||||
active_section.border_title = "Activate Entry"
|
active_section.border_title = "Activate Entry"
|
||||||
|
|
||||||
# Focus IP input
|
# Focus IP input
|
||||||
ip_input = self.query_one("#ip-address-input", Input)
|
ip_input = self.query_one("#ip-address-input", Input)
|
||||||
ip_input.focus()
|
ip_input.focus()
|
||||||
|
|
@ -139,14 +143,16 @@ class AddEntryModal(ModalScreen):
|
||||||
dns_section = self.query_one("#dns-section")
|
dns_section = self.query_one("#dns-section")
|
||||||
active_checkbox = self.query_one("#active-checkbox", Checkbox)
|
active_checkbox = self.query_one("#active-checkbox", Checkbox)
|
||||||
active_section = self.query_one("#active-checkbox").parent
|
active_section = self.query_one("#active-checkbox").parent
|
||||||
|
|
||||||
ip_section.add_class("hidden")
|
ip_section.add_class("hidden")
|
||||||
dns_section.remove_class("hidden")
|
dns_section.remove_class("hidden")
|
||||||
|
|
||||||
# Set checkbox to inactive for DNS entries (will be activated after resolution)
|
# Set checkbox to inactive for DNS entries (will be activated after resolution)
|
||||||
active_checkbox.value = False
|
active_checkbox.value = False
|
||||||
active_section.border_title = "Activate Entry (DNS entries activate after resolution)"
|
active_section.border_title = (
|
||||||
|
"Activate Entry (DNS entries activate after resolution)"
|
||||||
|
)
|
||||||
|
|
||||||
# Focus DNS input
|
# Focus DNS input
|
||||||
dns_input = self.query_one("#dns-name-input", Input)
|
dns_input = self.query_one("#dns-name-input", Input)
|
||||||
dns_input.focus()
|
dns_input.focus()
|
||||||
|
|
@ -165,7 +171,10 @@ class AddEntryModal(ModalScreen):
|
||||||
|
|
||||||
# Determine entry type
|
# Determine entry type
|
||||||
radio_set = self.query_one("#entry-type-radio", RadioSet)
|
radio_set = self.query_one("#entry-type-radio", RadioSet)
|
||||||
is_dns_entry = radio_set.pressed_button and radio_set.pressed_button.id == "dns-entry-radio"
|
is_dns_entry = (
|
||||||
|
radio_set.pressed_button
|
||||||
|
and radio_set.pressed_button.id == "dns-entry-radio"
|
||||||
|
)
|
||||||
|
|
||||||
# Get form values
|
# Get form values
|
||||||
ip_address = self.query_one("#ip-address-input", Input).value.strip()
|
ip_address = self.query_one("#ip-address-input", Input).value.strip()
|
||||||
|
|
@ -193,14 +202,15 @@ class AddEntryModal(ModalScreen):
|
||||||
)
|
)
|
||||||
# Add DNS name field
|
# Add DNS name field
|
||||||
new_entry.dns_name = dns_name
|
new_entry.dns_name = dns_name
|
||||||
|
|
||||||
# Add resolution status fields if they don't exist
|
# Add resolution status fields if they don't exist
|
||||||
if not hasattr(new_entry, 'resolved_ip'):
|
if not hasattr(new_entry, "resolved_ip"):
|
||||||
new_entry.resolved_ip = None
|
new_entry.resolved_ip = None
|
||||||
if not hasattr(new_entry, 'last_resolved'):
|
if not hasattr(new_entry, "last_resolved"):
|
||||||
new_entry.last_resolved = None
|
new_entry.last_resolved = None
|
||||||
if not hasattr(new_entry, 'dns_resolution_status'):
|
if not hasattr(new_entry, "dns_resolution_status"):
|
||||||
from ..core.dns import DNSResolutionStatus
|
from ..core.dns import DNSResolutionStatus
|
||||||
|
|
||||||
new_entry.dns_resolution_status = DNSResolutionStatus.NOT_RESOLVED
|
new_entry.dns_resolution_status = DNSResolutionStatus.NOT_RESOLVED
|
||||||
else:
|
else:
|
||||||
# IP entry
|
# IP entry
|
||||||
|
|
@ -227,7 +237,9 @@ class AddEntryModal(ModalScreen):
|
||||||
"""Cancel entry creation and close modal."""
|
"""Cancel entry creation and close modal."""
|
||||||
self.dismiss(None)
|
self.dismiss(None)
|
||||||
|
|
||||||
def _validate_input(self, ip_address: str, dns_name: str, hostnames_str: str, is_dns_entry: bool) -> bool:
|
def _validate_input(
|
||||||
|
self, ip_address: str, dns_name: str, hostnames_str: str, is_dns_entry: bool
|
||||||
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Validate user input.
|
Validate user input.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,15 @@ all the handlers and provides the primary user interface.
|
||||||
|
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.containers import Horizontal, Vertical
|
from textual.containers import Horizontal, Vertical
|
||||||
from textual.widgets import Header, Static, DataTable, Input, Checkbox, RadioSet, RadioButton
|
from textual.widgets import (
|
||||||
|
Header,
|
||||||
|
Static,
|
||||||
|
DataTable,
|
||||||
|
Input,
|
||||||
|
Checkbox,
|
||||||
|
RadioSet,
|
||||||
|
RadioButton,
|
||||||
|
)
|
||||||
from textual.reactive import reactive
|
from textual.reactive import reactive
|
||||||
|
|
||||||
from ..core.parser import HostsParser
|
from ..core.parser import HostsParser
|
||||||
|
|
@ -66,7 +74,7 @@ class HostsManagerApp(App):
|
||||||
dns_config = self.config.get("dns_resolution", {})
|
dns_config = self.config.get("dns_resolution", {})
|
||||||
self.dns_service = DNSService(
|
self.dns_service = DNSService(
|
||||||
enabled=dns_config.get("enabled", True),
|
enabled=dns_config.get("enabled", True),
|
||||||
timeout=dns_config.get("timeout", 5.0)
|
timeout=dns_config.get("timeout", 5.0),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize filtering system
|
# Initialize filtering system
|
||||||
|
|
@ -176,14 +184,24 @@ class HostsManagerApp(App):
|
||||||
# Edit form (initially hidden)
|
# Edit form (initially hidden)
|
||||||
with Vertical(id="entry-edit-form", classes="entry-form hidden"):
|
with Vertical(id="entry-edit-form", classes="entry-form hidden"):
|
||||||
# Entry Type Selection
|
# Entry Type Selection
|
||||||
with Vertical(classes="default-flex-section section-no-top-margin") as entry_type:
|
with Vertical(
|
||||||
|
classes="default-flex-section section-no-top-margin"
|
||||||
|
) as entry_type:
|
||||||
entry_type.border_title = "Entry Type"
|
entry_type.border_title = "Entry Type"
|
||||||
with RadioSet(id="edit-entry-type-radio", classes="default-radio-set"):
|
with RadioSet(
|
||||||
yield RadioButton("IP Address Entry", value=True, id="edit-ip-entry-radio")
|
id="edit-entry-type-radio", classes="default-radio-set"
|
||||||
yield RadioButton("DNS Name Entry", id="edit-dns-entry-radio")
|
):
|
||||||
|
yield RadioButton(
|
||||||
|
"IP Address Entry", value=True, id="edit-ip-entry-radio"
|
||||||
|
)
|
||||||
|
yield RadioButton(
|
||||||
|
"DNS Name Entry", id="edit-dns-entry-radio"
|
||||||
|
)
|
||||||
|
|
||||||
# IP Address Section
|
# IP Address Section
|
||||||
with Vertical(classes="default-section", id="edit-ip-section") as ip_address:
|
with Vertical(
|
||||||
|
classes="default-section", id="edit-ip-section"
|
||||||
|
) as ip_address:
|
||||||
ip_address.border_title = "IP Address"
|
ip_address.border_title = "IP Address"
|
||||||
yield Input(
|
yield Input(
|
||||||
placeholder="Enter IP address",
|
placeholder="Enter IP address",
|
||||||
|
|
@ -192,7 +210,9 @@ class HostsManagerApp(App):
|
||||||
)
|
)
|
||||||
|
|
||||||
# DNS Name Section (initially hidden)
|
# DNS Name Section (initially hidden)
|
||||||
with Vertical(classes="default-section hidden", id="edit-dns-section") as dns_name:
|
with Vertical(
|
||||||
|
classes="default-section hidden", id="edit-dns-section"
|
||||||
|
) as dns_name:
|
||||||
dns_name.border_title = "DNS Name (to resolve)"
|
dns_name.border_title = "DNS Name (to resolve)"
|
||||||
yield Input(
|
yield Input(
|
||||||
placeholder="e.g., example.com",
|
placeholder="e.g., example.com",
|
||||||
|
|
@ -388,7 +408,9 @@ class HostsManagerApp(App):
|
||||||
# Update search term and filter entries
|
# Update search term and filter entries
|
||||||
self.search_term = event.value.strip()
|
self.search_term = event.value.strip()
|
||||||
# Also update the current filter options to keep them synchronized
|
# Also update the current filter options to keep them synchronized
|
||||||
self.current_filter_options.search_term = self.search_term if self.search_term else None
|
self.current_filter_options.search_term = (
|
||||||
|
self.search_term if self.search_term else None
|
||||||
|
)
|
||||||
self.table_handler.populate_entries_table()
|
self.table_handler.populate_entries_table()
|
||||||
self.details_handler.update_entry_details()
|
self.details_handler.update_entry_details()
|
||||||
else:
|
else:
|
||||||
|
|
@ -404,7 +426,10 @@ class HostsManagerApp(App):
|
||||||
|
|
||||||
def on_radio_set_changed(self, event) -> None:
|
def on_radio_set_changed(self, event) -> None:
|
||||||
"""Handle entry type radio button changes in edit mode."""
|
"""Handle entry type radio button changes in edit mode."""
|
||||||
if hasattr(event, 'radio_set') and event.radio_set.id == "edit-entry-type-radio":
|
if (
|
||||||
|
hasattr(event, "radio_set")
|
||||||
|
and event.radio_set.id == "edit-entry-type-radio"
|
||||||
|
):
|
||||||
pressed_radio = event.pressed
|
pressed_radio = event.pressed
|
||||||
if pressed_radio and pressed_radio.id == "edit-ip-entry-radio":
|
if pressed_radio and pressed_radio.id == "edit-ip-entry-radio":
|
||||||
# Handle switch to IP entry type
|
# Handle switch to IP entry type
|
||||||
|
|
@ -526,7 +551,7 @@ class HostsManagerApp(App):
|
||||||
"hostnames": entry.hostnames.copy(),
|
"hostnames": entry.hostnames.copy(),
|
||||||
"comment": entry.comment,
|
"comment": entry.comment,
|
||||||
"is_active": entry.is_active,
|
"is_active": entry.is_active,
|
||||||
"dns_name": getattr(entry, 'dns_name', None),
|
"dns_name": getattr(entry, "dns_name", None),
|
||||||
}
|
}
|
||||||
|
|
||||||
self.entry_edit_mode = True
|
self.entry_edit_mode = True
|
||||||
|
|
@ -584,21 +609,27 @@ class HostsManagerApp(App):
|
||||||
result = self.manager.execute_add_command(self.hosts_file, new_entry)
|
result = self.manager.execute_add_command(self.hosts_file, new_entry)
|
||||||
if result.success:
|
if result.success:
|
||||||
# Save the changes
|
# Save the changes
|
||||||
save_success, save_message = self.manager.save_hosts_file(self.hosts_file)
|
save_success, save_message = self.manager.save_hosts_file(
|
||||||
|
self.hosts_file
|
||||||
|
)
|
||||||
if save_success:
|
if save_success:
|
||||||
# Refresh the table
|
# Refresh the table
|
||||||
self.table_handler.populate_entries_table()
|
self.table_handler.populate_entries_table()
|
||||||
# Move cursor to the newly added entry (last entry)
|
# Move cursor to the newly added entry (last entry)
|
||||||
self.selected_entry_index = len(self.hosts_file.entries) - 1
|
self.selected_entry_index = len(self.hosts_file.entries) - 1
|
||||||
self.table_handler.restore_cursor_position(new_entry)
|
self.table_handler.restore_cursor_position(new_entry)
|
||||||
|
|
||||||
# For DNS entries, trigger resolution and provide feedback
|
# For DNS entries, trigger resolution and provide feedback
|
||||||
if hasattr(new_entry, 'dns_name') and new_entry.dns_name:
|
if hasattr(new_entry, "dns_name") and new_entry.dns_name:
|
||||||
self.update_status(f"✅ {result.message} - Starting DNS resolution for {new_entry.dns_name}")
|
self.update_status(
|
||||||
|
f"✅ {result.message} - Starting DNS resolution for {new_entry.dns_name}"
|
||||||
|
)
|
||||||
# Trigger DNS resolution in background
|
# Trigger DNS resolution in background
|
||||||
self._resolve_new_dns_entry(new_entry)
|
self._resolve_new_dns_entry(new_entry)
|
||||||
else:
|
else:
|
||||||
self.update_status(f"✅ {result.message} - Changes saved automatically")
|
self.update_status(
|
||||||
|
f"✅ {result.message} - Changes saved automatically"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.update_status(f"Entry added but save failed: {save_message}")
|
self.update_status(f"Entry added but save failed: {save_message}")
|
||||||
else:
|
else:
|
||||||
|
|
@ -638,16 +669,22 @@ class HostsManagerApp(App):
|
||||||
)
|
)
|
||||||
if result.success:
|
if result.success:
|
||||||
# Save the changes
|
# Save the changes
|
||||||
save_success, save_message = self.manager.save_hosts_file(self.hosts_file)
|
save_success, save_message = self.manager.save_hosts_file(
|
||||||
|
self.hosts_file
|
||||||
|
)
|
||||||
if save_success:
|
if save_success:
|
||||||
# Adjust selected index if needed
|
# Adjust selected index if needed
|
||||||
if self.selected_entry_index >= len(self.hosts_file.entries):
|
if self.selected_entry_index >= len(self.hosts_file.entries):
|
||||||
self.selected_entry_index = max(0, len(self.hosts_file.entries) - 1)
|
self.selected_entry_index = max(
|
||||||
|
0, len(self.hosts_file.entries) - 1
|
||||||
|
)
|
||||||
|
|
||||||
# Refresh the table
|
# Refresh the table
|
||||||
self.table_handler.populate_entries_table()
|
self.table_handler.populate_entries_table()
|
||||||
self.details_handler.update_entry_details()
|
self.details_handler.update_entry_details()
|
||||||
self.update_status(f"✅ {result.message} - Changes saved automatically")
|
self.update_status(
|
||||||
|
f"✅ {result.message} - Changes saved automatically"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.update_status(f"Entry deleted but save failed: {save_message}")
|
self.update_status(f"Entry deleted but save failed: {save_message}")
|
||||||
else:
|
else:
|
||||||
|
|
@ -671,7 +708,7 @@ class HostsManagerApp(App):
|
||||||
|
|
||||||
# Get description before undoing
|
# Get description before undoing
|
||||||
description = self.manager.get_undo_description()
|
description = self.manager.get_undo_description()
|
||||||
|
|
||||||
# Perform undo
|
# Perform undo
|
||||||
result = self.manager.undo_last_operation(self.hosts_file)
|
result = self.manager.undo_last_operation(self.hosts_file)
|
||||||
if result.success:
|
if result.success:
|
||||||
|
|
@ -694,7 +731,7 @@ class HostsManagerApp(App):
|
||||||
|
|
||||||
# Get description before redoing
|
# Get description before redoing
|
||||||
description = self.manager.get_redo_description()
|
description = self.manager.get_redo_description()
|
||||||
|
|
||||||
# Perform redo
|
# Perform redo
|
||||||
result = self.manager.redo_last_operation(self.hosts_file)
|
result = self.manager.redo_last_operation(self.hosts_file)
|
||||||
if result.success:
|
if result.success:
|
||||||
|
|
@ -734,25 +771,25 @@ class HostsManagerApp(App):
|
||||||
try:
|
try:
|
||||||
# Extract DNS names (not hostnames!) from entries
|
# Extract DNS names (not hostnames!) from entries
|
||||||
dns_names = [entry.dns_name for entry in dns_entries if entry.dns_name]
|
dns_names = [entry.dns_name for entry in dns_entries if entry.dns_name]
|
||||||
|
|
||||||
if not dns_names:
|
if not dns_names:
|
||||||
self.update_status("No valid DNS names found to resolve")
|
self.update_status("No valid DNS names found to resolve")
|
||||||
return
|
return
|
||||||
|
|
||||||
resolved_count = 0
|
resolved_count = 0
|
||||||
failed_count = 0
|
failed_count = 0
|
||||||
|
|
||||||
# Resolve each DNS name and apply results back to entries
|
# Resolve each DNS name and apply results back to entries
|
||||||
for dns_name in dns_names:
|
for dns_name in dns_names:
|
||||||
resolution = await self.dns_service.resolve_entry_async(dns_name)
|
resolution = await self.dns_service.resolve_entry_async(dns_name)
|
||||||
|
|
||||||
# Find the corresponding entry and update it
|
# Find the corresponding entry and update it
|
||||||
for entry in dns_entries:
|
for entry in dns_entries:
|
||||||
if entry.dns_name == dns_name:
|
if entry.dns_name == dns_name:
|
||||||
# Apply resolution results to entry fields
|
# Apply resolution results to entry fields
|
||||||
entry.last_resolved = resolution.resolved_at
|
entry.last_resolved = resolution.resolved_at
|
||||||
entry.dns_resolution_status = resolution.status.value
|
entry.dns_resolution_status = resolution.status.value
|
||||||
|
|
||||||
if resolution.is_success():
|
if resolution.is_success():
|
||||||
# Update both resolved_ip and ip_address for the hosts file
|
# Update both resolved_ip and ip_address for the hosts file
|
||||||
entry.ip_address = resolution.resolved_ip
|
entry.ip_address = resolution.resolved_ip
|
||||||
|
|
@ -761,27 +798,37 @@ class HostsManagerApp(App):
|
||||||
else:
|
else:
|
||||||
failed_count += 1
|
failed_count += 1
|
||||||
break
|
break
|
||||||
|
|
||||||
# Save hosts file with updated DNS information
|
# Save hosts file with updated DNS information
|
||||||
if resolved_count > 0 or failed_count > 0:
|
if resolved_count > 0 or failed_count > 0:
|
||||||
save_success, save_message = self.manager.save_hosts_file(self.hosts_file)
|
save_success, save_message = self.manager.save_hosts_file(
|
||||||
|
self.hosts_file
|
||||||
|
)
|
||||||
if not save_success:
|
if not save_success:
|
||||||
self.update_status(f"❌ DNS resolution completed but save failed: {save_message}")
|
self.update_status(
|
||||||
|
f"❌ DNS resolution completed but save failed: {save_message}"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Update the UI and restore cursor position
|
# Update the UI and restore cursor position
|
||||||
self.table_handler.populate_entries_table()
|
self.table_handler.populate_entries_table()
|
||||||
self.table_handler.restore_cursor_position(current_entry)
|
self.table_handler.restore_cursor_position(current_entry)
|
||||||
self.details_handler.update_entry_details()
|
self.details_handler.update_entry_details()
|
||||||
|
|
||||||
# Provide detailed status message
|
# Provide detailed status message
|
||||||
if failed_count == 0:
|
if failed_count == 0:
|
||||||
self.update_status(f"✅ DNS resolution completed for {resolved_count} entries")
|
self.update_status(
|
||||||
|
f"✅ DNS resolution completed for {resolved_count} entries"
|
||||||
|
)
|
||||||
elif resolved_count == 0:
|
elif resolved_count == 0:
|
||||||
self.update_status(f"❌ DNS resolution failed for all {failed_count} entries")
|
self.update_status(
|
||||||
|
f"❌ DNS resolution failed for all {failed_count} entries"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.update_status(f"⚠️ DNS resolution: {resolved_count} succeeded, {failed_count} failed")
|
self.update_status(
|
||||||
|
f"⚠️ DNS resolution: {resolved_count} succeeded, {failed_count} failed"
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.update_status(f"❌ DNS resolution failed: {e}")
|
self.update_status(f"❌ DNS resolution failed: {e}")
|
||||||
|
|
||||||
|
|
@ -806,9 +853,9 @@ class HostsManagerApp(App):
|
||||||
return
|
return
|
||||||
|
|
||||||
entry = self.hosts_file.entries[self.selected_entry_index]
|
entry = self.hosts_file.entries[self.selected_entry_index]
|
||||||
|
|
||||||
# Check if the entry has a DNS name to resolve
|
# Check if the entry has a DNS name to resolve
|
||||||
if not hasattr(entry, 'dns_name') or not entry.dns_name:
|
if not hasattr(entry, "dns_name") or not entry.dns_name:
|
||||||
self.update_status("❌ Selected entry has no DNS name to resolve")
|
self.update_status("❌ Selected entry has no DNS name to resolve")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -818,43 +865,53 @@ class HostsManagerApp(App):
|
||||||
async def update_single_dns():
|
async def update_single_dns():
|
||||||
try:
|
try:
|
||||||
dns_name = entry.dns_name
|
dns_name = entry.dns_name
|
||||||
|
|
||||||
# Resolve the DNS name
|
# Resolve the DNS name
|
||||||
resolution = await self.dns_service.resolve_entry_async(dns_name)
|
resolution = await self.dns_service.resolve_entry_async(dns_name)
|
||||||
|
|
||||||
# Apply resolution results to entry fields
|
# Apply resolution results to entry fields
|
||||||
entry.last_resolved = resolution.resolved_at
|
entry.last_resolved = resolution.resolved_at
|
||||||
entry.dns_resolution_status = resolution.status.value
|
entry.dns_resolution_status = resolution.status.value
|
||||||
|
|
||||||
if resolution.is_success():
|
if resolution.is_success():
|
||||||
# Update both resolved_ip and ip_address for the hosts file
|
# Update both resolved_ip and ip_address for the hosts file
|
||||||
entry.ip_address = resolution.resolved_ip
|
entry.ip_address = resolution.resolved_ip
|
||||||
entry.resolved_ip = resolution.resolved_ip
|
entry.resolved_ip = resolution.resolved_ip
|
||||||
|
|
||||||
# Save hosts file with updated DNS information
|
# Save hosts file with updated DNS information
|
||||||
save_success, save_message = self.manager.save_hosts_file(self.hosts_file)
|
save_success, save_message = self.manager.save_hosts_file(
|
||||||
|
self.hosts_file
|
||||||
|
)
|
||||||
if not save_success:
|
if not save_success:
|
||||||
self.update_status(f"❌ DNS resolution completed but save failed: {save_message}")
|
self.update_status(
|
||||||
|
f"❌ DNS resolution completed but save failed: {save_message}"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Update the UI and restore cursor position
|
# Update the UI and restore cursor position
|
||||||
self.table_handler.populate_entries_table()
|
self.table_handler.populate_entries_table()
|
||||||
self.table_handler.restore_cursor_position(current_entry)
|
self.table_handler.restore_cursor_position(current_entry)
|
||||||
self.details_handler.update_entry_details()
|
self.details_handler.update_entry_details()
|
||||||
|
|
||||||
self.update_status(f"✅ DNS updated: {dns_name} → {resolution.resolved_ip}")
|
self.update_status(
|
||||||
|
f"✅ DNS updated: {dns_name} → {resolution.resolved_ip}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Resolution failed, save the status update
|
# Resolution failed, save the status update
|
||||||
save_success, save_message = self.manager.save_hosts_file(self.hosts_file)
|
save_success, save_message = self.manager.save_hosts_file(
|
||||||
|
self.hosts_file
|
||||||
|
)
|
||||||
if save_success:
|
if save_success:
|
||||||
# Update the UI to show failed status and restore cursor position
|
# Update the UI to show failed status and restore cursor position
|
||||||
self.table_handler.populate_entries_table()
|
self.table_handler.populate_entries_table()
|
||||||
self.table_handler.restore_cursor_position(current_entry)
|
self.table_handler.restore_cursor_position(current_entry)
|
||||||
self.details_handler.update_entry_details()
|
self.details_handler.update_entry_details()
|
||||||
|
|
||||||
error_msg = resolution.error_message or "Unknown error"
|
error_msg = resolution.error_message or "Unknown error"
|
||||||
self.update_status(f"❌ DNS resolution failed for {dns_name}: {error_msg}")
|
self.update_status(
|
||||||
|
f"❌ DNS resolution failed for {dns_name}: {error_msg}"
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.update_status(f"❌ DNS resolution error: {e}")
|
self.update_status(f"❌ DNS resolution error: {e}")
|
||||||
|
|
||||||
|
|
@ -864,6 +921,7 @@ class HostsManagerApp(App):
|
||||||
|
|
||||||
def action_show_filters(self) -> None:
|
def action_show_filters(self) -> None:
|
||||||
"""Show advanced filtering modal."""
|
"""Show advanced filtering modal."""
|
||||||
|
|
||||||
def handle_filter_result(filter_options: FilterOptions) -> None:
|
def handle_filter_result(filter_options: FilterOptions) -> None:
|
||||||
if filter_options is None:
|
if filter_options is None:
|
||||||
# User cancelled
|
# User cancelled
|
||||||
|
|
@ -872,7 +930,7 @@ class HostsManagerApp(App):
|
||||||
|
|
||||||
# Apply the new filter options
|
# Apply the new filter options
|
||||||
self.current_filter_options = filter_options
|
self.current_filter_options = filter_options
|
||||||
|
|
||||||
# Update the search term from filter if it has one
|
# Update the search term from filter if it has one
|
||||||
if filter_options.search_term:
|
if filter_options.search_term:
|
||||||
self.search_term = filter_options.search_term
|
self.search_term = filter_options.search_term
|
||||||
|
|
@ -894,72 +952,92 @@ class HostsManagerApp(App):
|
||||||
# Refresh the table with new filtering
|
# Refresh the table with new filtering
|
||||||
self.table_handler.populate_entries_table()
|
self.table_handler.populate_entries_table()
|
||||||
self.details_handler.update_entry_details()
|
self.details_handler.update_entry_details()
|
||||||
|
|
||||||
# Get filter statistics for status message
|
# Get filter statistics for status message
|
||||||
counts = self.entry_filter.count_filtered_entries(self.hosts_file.entries, filter_options)
|
counts = self.entry_filter.count_filtered_entries(
|
||||||
preset_info = f" (preset: {filter_options.preset_name})" if filter_options.preset_name else ""
|
self.hosts_file.entries, filter_options
|
||||||
self.update_status(f"✅ Filter applied: showing {counts['filtered']} of {counts['total']} entries{preset_info}")
|
)
|
||||||
|
preset_info = (
|
||||||
|
f" (preset: {filter_options.preset_name})"
|
||||||
|
if filter_options.preset_name
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
self.update_status(
|
||||||
|
f"✅ Filter applied: showing {counts['filtered']} of {counts['total']} entries{preset_info}"
|
||||||
|
)
|
||||||
|
|
||||||
# Show the filter modal with current options and entries for preview
|
# Show the filter modal with current options and entries for preview
|
||||||
self.push_screen(
|
self.push_screen(
|
||||||
FilterModal(
|
FilterModal(
|
||||||
initial_options=self.current_filter_options,
|
initial_options=self.current_filter_options,
|
||||||
entries=self.hosts_file.entries,
|
entries=self.hosts_file.entries,
|
||||||
entry_filter=self.entry_filter
|
entry_filter=self.entry_filter,
|
||||||
),
|
),
|
||||||
handle_filter_result
|
handle_filter_result,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _resolve_new_dns_entry(self, entry) -> None:
|
def _resolve_new_dns_entry(self, entry) -> None:
|
||||||
"""Trigger DNS resolution for a newly added DNS entry."""
|
"""Trigger DNS resolution for a newly added DNS entry."""
|
||||||
if not hasattr(entry, 'dns_name') or not entry.dns_name:
|
if not hasattr(entry, "dns_name") or not entry.dns_name:
|
||||||
return
|
return
|
||||||
|
|
||||||
async def resolve_and_activate():
|
async def resolve_and_activate():
|
||||||
try:
|
try:
|
||||||
# Resolve the DNS name
|
# Resolve the DNS name
|
||||||
resolution = await self.dns_service.resolve_entry_async(entry.dns_name)
|
resolution = await self.dns_service.resolve_entry_async(entry.dns_name)
|
||||||
|
|
||||||
if resolution.is_success():
|
if resolution.is_success():
|
||||||
# Find the entry in the hosts file and update it
|
# Find the entry in the hosts file and update it
|
||||||
for hosts_entry in self.hosts_file.entries:
|
for hosts_entry in self.hosts_file.entries:
|
||||||
if (hasattr(hosts_entry, 'dns_name') and
|
if (
|
||||||
hosts_entry.dns_name == entry.dns_name and
|
hasattr(hosts_entry, "dns_name")
|
||||||
hosts_entry.hostnames == entry.hostnames):
|
and hosts_entry.dns_name == entry.dns_name
|
||||||
|
and hosts_entry.hostnames == entry.hostnames
|
||||||
|
):
|
||||||
# Update the entry with resolved IP
|
# Update the entry with resolved IP
|
||||||
hosts_entry.ip_address = resolution.resolved_ip
|
hosts_entry.ip_address = resolution.resolved_ip
|
||||||
hosts_entry.resolved_ip = resolution.resolved_ip
|
hosts_entry.resolved_ip = resolution.resolved_ip
|
||||||
hosts_entry.last_resolved = resolution.resolved_at
|
hosts_entry.last_resolved = resolution.resolved_at
|
||||||
hosts_entry.dns_resolution_status = resolution.status.value
|
hosts_entry.dns_resolution_status = resolution.status.value
|
||||||
hosts_entry.is_active = True # Activate the entry
|
hosts_entry.is_active = True # Activate the entry
|
||||||
|
|
||||||
# Save the updated hosts file
|
# Save the updated hosts file
|
||||||
save_success, save_message = self.manager.save_hosts_file(self.hosts_file)
|
save_success, save_message = self.manager.save_hosts_file(
|
||||||
|
self.hosts_file
|
||||||
|
)
|
||||||
if save_success:
|
if save_success:
|
||||||
# Update UI - use direct calls since we're in the same async context
|
# Update UI - use direct calls since we're in the same async context
|
||||||
self.table_handler.populate_entries_table()
|
self.table_handler.populate_entries_table()
|
||||||
self.details_handler.update_entry_details()
|
self.details_handler.update_entry_details()
|
||||||
self.update_status(f"✅ DNS resolved: {entry.dns_name} → {resolution.resolved_ip} (entry activated)")
|
self.update_status(
|
||||||
|
f"✅ DNS resolved: {entry.dns_name} → {resolution.resolved_ip} (entry activated)"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.update_status(f"❌ DNS resolved but save failed: {save_message}")
|
self.update_status(
|
||||||
|
f"❌ DNS resolved but save failed: {save_message}"
|
||||||
|
)
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
# Resolution failed, update status but keep entry inactive
|
# Resolution failed, update status but keep entry inactive
|
||||||
for hosts_entry in self.hosts_file.entries:
|
for hosts_entry in self.hosts_file.entries:
|
||||||
if (hasattr(hosts_entry, 'dns_name') and
|
if (
|
||||||
hosts_entry.dns_name == entry.dns_name and
|
hasattr(hosts_entry, "dns_name")
|
||||||
hosts_entry.hostnames == entry.hostnames):
|
and hosts_entry.dns_name == entry.dns_name
|
||||||
|
and hosts_entry.hostnames == entry.hostnames
|
||||||
|
):
|
||||||
hosts_entry.dns_resolution_status = resolution.status.value
|
hosts_entry.dns_resolution_status = resolution.status.value
|
||||||
hosts_entry.last_resolved = resolution.resolved_at
|
hosts_entry.last_resolved = resolution.resolved_at
|
||||||
break
|
break
|
||||||
|
|
||||||
self.update_status(f"❌ DNS resolution failed for {entry.dns_name}: {resolution.error_message or 'Unknown error'}")
|
self.update_status(
|
||||||
|
f"❌ DNS resolution failed for {entry.dns_name}: {resolution.error_message or 'Unknown error'}"
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.update_status(f"❌ DNS resolution error for {entry.dns_name}: {str(e)}")
|
self.update_status(
|
||||||
|
f"❌ DNS resolution error for {entry.dns_name}: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
# Start the resolution in background
|
# Start the resolution in background
|
||||||
self.run_worker(resolve_and_activate(), exclusive=False)
|
self.run_worker(resolve_and_activate(), exclusive=False)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -138,8 +138,10 @@ class DetailsHandler:
|
||||||
# Get the three separate DNS input fields
|
# Get the three separate DNS input fields
|
||||||
dns_name_input = self.app.query_one("#details-dns-name-input", Input)
|
dns_name_input = self.app.query_one("#details-dns-name-input", Input)
|
||||||
dns_status_input = self.app.query_one("#details-dns-status-input", Input)
|
dns_status_input = self.app.query_one("#details-dns-status-input", Input)
|
||||||
dns_resolved_input = self.app.query_one("#details-dns-resolved-input", Input)
|
dns_resolved_input = self.app.query_one(
|
||||||
|
"#details-dns-resolved-input", Input
|
||||||
|
)
|
||||||
|
|
||||||
if not entry.has_dns_name():
|
if not entry.has_dns_name():
|
||||||
# Clear all DNS fields if no DNS information
|
# Clear all DNS fields if no DNS information
|
||||||
dns_name_input.value = ""
|
dns_name_input.value = ""
|
||||||
|
|
@ -162,13 +164,17 @@ class DetailsHandler:
|
||||||
"resolved": "Resolved",
|
"resolved": "Resolved",
|
||||||
"failed": "Resolution failed",
|
"failed": "Resolution failed",
|
||||||
"match": "IP matches DNS",
|
"match": "IP matches DNS",
|
||||||
"mismatch": "IP differs from DNS"
|
"mismatch": "IP differs from DNS",
|
||||||
}.get(entry.dns_resolution_status, entry.dns_resolution_status)
|
}.get(entry.dns_resolution_status, entry.dns_resolution_status)
|
||||||
|
|
||||||
# Add resolved IP to status if available
|
# Add resolved IP to status if available
|
||||||
if entry.resolved_ip and entry.dns_resolution_status in ["resolved", "match", "mismatch"]:
|
if entry.resolved_ip and entry.dns_resolution_status in [
|
||||||
|
"resolved",
|
||||||
|
"match",
|
||||||
|
"mismatch",
|
||||||
|
]:
|
||||||
status_text += f" ({entry.resolved_ip})"
|
status_text += f" ({entry.resolved_ip})"
|
||||||
|
|
||||||
dns_status_input.value = status_text
|
dns_status_input.value = status_text
|
||||||
dns_status_input.placeholder = ""
|
dns_status_input.placeholder = ""
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from ..core.dns import DNSService
|
||||||
class DNSStatusWidget(Static):
|
class DNSStatusWidget(Static):
|
||||||
"""
|
"""
|
||||||
Widget to display DNS resolution service status.
|
Widget to display DNS resolution service status.
|
||||||
|
|
||||||
Shows visual indicators for DNS service status and resolution progress.
|
Shows visual indicators for DNS service status and resolution progress.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -61,12 +61,12 @@ class DNSStatusWidget(Static):
|
||||||
status_parts.append(f"{self.resolved_count} resolved")
|
status_parts.append(f"{self.resolved_count} resolved")
|
||||||
if self.failed_count > 0:
|
if self.failed_count > 0:
|
||||||
status_parts.append(f"{self.failed_count} failed")
|
status_parts.append(f"{self.failed_count} failed")
|
||||||
|
|
||||||
if status_parts:
|
if status_parts:
|
||||||
status_text = f"DNS: Active ({', '.join(status_parts)})"
|
status_text = f"DNS: Active ({', '.join(status_parts)})"
|
||||||
else:
|
else:
|
||||||
status_text = "DNS: Active"
|
status_text = "DNS: Active"
|
||||||
|
|
||||||
text_widget.update(status_text)
|
text_widget.update(status_text)
|
||||||
indicator.remove_class("dns-disabled")
|
indicator.remove_class("dns-disabled")
|
||||||
indicator.remove_class("dns-resolving")
|
indicator.remove_class("dns-resolving")
|
||||||
|
|
@ -95,14 +95,20 @@ class DNSStatusWidget(Static):
|
||||||
def update_from_service(self) -> None:
|
def update_from_service(self) -> None:
|
||||||
"""Update status from the current DNS service state."""
|
"""Update status from the current DNS service state."""
|
||||||
self.dns_enabled = self.dns_service.enabled
|
self.dns_enabled = self.dns_service.enabled
|
||||||
|
|
||||||
# Count DNS resolution states from the service
|
# Count DNS resolution states from the service
|
||||||
if hasattr(self.dns_service, '_resolution_cache'):
|
if hasattr(self.dns_service, "_resolution_cache"):
|
||||||
cache = self.dns_service._resolution_cache
|
cache = self.dns_service._resolution_cache
|
||||||
resolving = sum(1 for r in cache.values() if r.status == "RESOLVING")
|
resolving = sum(1 for r in cache.values() if r.status == "RESOLVING")
|
||||||
resolved = sum(1 for r in cache.values() if r.status in ["RESOLVED", "IP_MATCH"])
|
resolved = sum(
|
||||||
failed = sum(1 for r in cache.values() if r.status in ["RESOLUTION_FAILED", "IP_MISMATCH"])
|
1 for r in cache.values() if r.status in ["RESOLVED", "IP_MATCH"]
|
||||||
|
)
|
||||||
|
failed = sum(
|
||||||
|
1
|
||||||
|
for r in cache.values()
|
||||||
|
if r.status in ["RESOLUTION_FAILED", "IP_MISMATCH"]
|
||||||
|
)
|
||||||
|
|
||||||
self.resolving_count = resolving
|
self.resolving_count = resolving
|
||||||
self.resolved_count = resolved
|
self.resolved_count = resolved
|
||||||
self.failed_count = failed
|
self.failed_count = failed
|
||||||
|
|
@ -117,7 +123,7 @@ class DNSStatusWidget(Static):
|
||||||
self.dns_service.stop()
|
self.dns_service.stop()
|
||||||
else:
|
else:
|
||||||
self.dns_service.start()
|
self.dns_service.start()
|
||||||
|
|
||||||
self.dns_enabled = self.dns_service.enabled
|
self.dns_enabled = self.dns_service.enabled
|
||||||
self.update_status()
|
self.update_status()
|
||||||
|
|
||||||
|
|
@ -133,7 +139,7 @@ class DNSStatusWidget(Static):
|
||||||
parts.append(f"{self.resolved_count} resolved")
|
parts.append(f"{self.resolved_count} resolved")
|
||||||
if self.failed_count > 0:
|
if self.failed_count > 0:
|
||||||
parts.append(f"{self.failed_count} failed")
|
parts.append(f"{self.failed_count} failed")
|
||||||
|
|
||||||
if parts:
|
if parts:
|
||||||
return f"DNS Active ({', '.join(parts)})"
|
return f"DNS Active ({', '.join(parts)})"
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,11 @@ class EditHandler:
|
||||||
self.app.hosts_file.entries
|
self.app.hosts_file.entries
|
||||||
):
|
):
|
||||||
return "ip" # Default to IP type
|
return "ip" # Default to IP type
|
||||||
|
|
||||||
entry = self.app.hosts_file.entries[self.app.selected_entry_index]
|
entry = self.app.hosts_file.entries[self.app.selected_entry_index]
|
||||||
|
|
||||||
# Check if entry has a DNS name field and it's not empty
|
# Check if entry has a DNS name field and it's not empty
|
||||||
if hasattr(entry, 'dns_name') and entry.dns_name:
|
if hasattr(entry, "dns_name") and entry.dns_name:
|
||||||
return "dns"
|
return "dns"
|
||||||
else:
|
else:
|
||||||
return "ip"
|
return "ip"
|
||||||
|
|
@ -38,32 +38,35 @@ class EditHandler:
|
||||||
if entry_type == "ip":
|
if entry_type == "ip":
|
||||||
# Show IP section, hide DNS section
|
# Show IP section, hide DNS section
|
||||||
self.update_field_visibility(show_ip=True, show_dns=False)
|
self.update_field_visibility(show_ip=True, show_dns=False)
|
||||||
|
|
||||||
# Focus IP input
|
# Focus IP input
|
||||||
try:
|
try:
|
||||||
ip_input = self.app.query_one("#ip-input", Input)
|
ip_input = self.app.query_one("#ip-input", Input)
|
||||||
ip_input.focus()
|
ip_input.focus()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
elif entry_type == "dns":
|
elif entry_type == "dns":
|
||||||
# Show DNS section, hide IP section
|
# Show DNS section, hide IP section
|
||||||
self.update_field_visibility(show_ip=False, show_dns=True)
|
self.update_field_visibility(show_ip=False, show_dns=True)
|
||||||
|
|
||||||
# Populate DNS field if we have existing entry data
|
# Populate DNS field if we have existing entry data
|
||||||
try:
|
try:
|
||||||
if (self.app.entry_edit_mode and
|
if (
|
||||||
self.app.hosts_file.entries and
|
self.app.entry_edit_mode
|
||||||
self.app.selected_entry_index < len(self.app.hosts_file.entries)):
|
and self.app.hosts_file.entries
|
||||||
|
and self.app.selected_entry_index < len(self.app.hosts_file.entries)
|
||||||
|
):
|
||||||
entry = self.app.hosts_file.entries[self.app.selected_entry_index]
|
entry = self.app.hosts_file.entries[self.app.selected_entry_index]
|
||||||
dns_input = self.app.query_one("#dns-name-input", Input)
|
dns_input = self.app.query_one("#dns-name-input", Input)
|
||||||
|
|
||||||
# Populate with existing DNS name if available
|
# Populate with existing DNS name if available
|
||||||
dns_name = getattr(entry, 'dns_name', '') or ''
|
dns_name = getattr(entry, "dns_name", "") or ""
|
||||||
if dns_name and not dns_input.value: # Only populate if field is empty
|
if (
|
||||||
|
dns_name and not dns_input.value
|
||||||
|
): # Only populate if field is empty
|
||||||
dns_input.value = dns_name
|
dns_input.value = dns_name
|
||||||
|
|
||||||
# Focus DNS input
|
# Focus DNS input
|
||||||
dns_input.focus()
|
dns_input.focus()
|
||||||
else:
|
else:
|
||||||
|
|
@ -78,17 +81,17 @@ class EditHandler:
|
||||||
try:
|
try:
|
||||||
ip_section = self.app.query_one("#edit-ip-section")
|
ip_section = self.app.query_one("#edit-ip-section")
|
||||||
dns_section = self.app.query_one("#edit-dns-section")
|
dns_section = self.app.query_one("#edit-dns-section")
|
||||||
|
|
||||||
if show_ip:
|
if show_ip:
|
||||||
ip_section.remove_class("hidden")
|
ip_section.remove_class("hidden")
|
||||||
else:
|
else:
|
||||||
ip_section.add_class("hidden")
|
ip_section.add_class("hidden")
|
||||||
|
|
||||||
if show_dns:
|
if show_dns:
|
||||||
dns_section.remove_class("hidden")
|
dns_section.remove_class("hidden")
|
||||||
else:
|
else:
|
||||||
dns_section.add_class("hidden")
|
dns_section.add_class("hidden")
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Sections not found, ignore silently
|
# Sections not found, ignore silently
|
||||||
pass
|
pass
|
||||||
|
|
@ -97,7 +100,7 @@ class EditHandler:
|
||||||
"""Initialize edit form with correct radio button state and field visibility."""
|
"""Initialize edit form with correct radio button state and field visibility."""
|
||||||
if not self.app.entry_edit_mode:
|
if not self.app.entry_edit_mode:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Use a timer to delay radio button setup to allow widgets to initialize
|
# Use a timer to delay radio button setup to allow widgets to initialize
|
||||||
self.app.set_timer(0.1, self._delayed_radio_setup)
|
self.app.set_timer(0.1, self._delayed_radio_setup)
|
||||||
|
|
||||||
|
|
@ -105,36 +108,36 @@ class EditHandler:
|
||||||
"""Set up radio buttons after a small delay to ensure widgets are ready."""
|
"""Set up radio buttons after a small delay to ensure widgets are ready."""
|
||||||
if not self.app.entry_edit_mode:
|
if not self.app.entry_edit_mode:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Determine current entry type
|
# Determine current entry type
|
||||||
entry_type = self.get_current_entry_type()
|
entry_type = self.get_current_entry_type()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get current entry for DNS field population
|
# Get current entry for DNS field population
|
||||||
entry = self.app.hosts_file.entries[self.app.selected_entry_index]
|
entry = self.app.hosts_file.entries[self.app.selected_entry_index]
|
||||||
|
|
||||||
# Get radio buttons
|
# Get radio buttons
|
||||||
ip_radio = self.app.query_one("#edit-ip-entry-radio")
|
ip_radio = self.app.query_one("#edit-ip-entry-radio")
|
||||||
dns_radio = self.app.query_one("#edit-dns-entry-radio")
|
dns_radio = self.app.query_one("#edit-dns-entry-radio")
|
||||||
|
|
||||||
# Set radio button values - let RadioSet manage pressed_button automatically
|
# Set radio button values - let RadioSet manage pressed_button automatically
|
||||||
if entry_type == "ip":
|
if entry_type == "ip":
|
||||||
# Clear DNS radio first, then set IP radio
|
# Clear DNS radio first, then set IP radio
|
||||||
dns_radio.value = False
|
dns_radio.value = False
|
||||||
ip_radio.value = True
|
ip_radio.value = True
|
||||||
else:
|
else:
|
||||||
# Clear IP radio first, then set DNS radio
|
# Clear IP radio first, then set DNS radio
|
||||||
ip_radio.value = False
|
ip_radio.value = False
|
||||||
dns_radio.value = True
|
dns_radio.value = True
|
||||||
|
|
||||||
# Update field visibility
|
# Update field visibility
|
||||||
self.handle_entry_type_change(entry_type)
|
self.handle_entry_type_change(entry_type)
|
||||||
|
|
||||||
# Populate DNS name field for DNS entries (after field is visible)
|
# Populate DNS name field for DNS entries (after field is visible)
|
||||||
if entry_type == "dns":
|
if entry_type == "dns":
|
||||||
dns_input = self.app.query_one("#dns-name-input", Input)
|
dns_input = self.app.query_one("#dns-name-input", Input)
|
||||||
dns_input.value = getattr(entry, 'dns_name', '') or ''
|
dns_input.value = getattr(entry, "dns_name", "") or ""
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Debug: Show what went wrong
|
# Debug: Show what went wrong
|
||||||
self.app.update_status(f"Debug: populate_edit_form error: {e}")
|
self.app.update_status(f"Debug: populate_edit_form error: {e}")
|
||||||
|
|
@ -149,7 +152,7 @@ class EditHandler:
|
||||||
hostname_input = self.app.query_one("#hostname-input", Input)
|
hostname_input = self.app.query_one("#hostname-input", Input)
|
||||||
comment_input = self.app.query_one("#comment-input", Input)
|
comment_input = self.app.query_one("#comment-input", Input)
|
||||||
active_checkbox = self.app.query_one("#active-checkbox", Checkbox)
|
active_checkbox = self.app.query_one("#active-checkbox", Checkbox)
|
||||||
|
|
||||||
# Try to get DNS input - may not exist in all contexts
|
# Try to get DNS input - may not exist in all contexts
|
||||||
try:
|
try:
|
||||||
dns_input = self.app.query_one("#dns-name-input", Input)
|
dns_input = self.app.query_one("#dns-name-input", Input)
|
||||||
|
|
@ -219,7 +222,7 @@ class EditHandler:
|
||||||
hostname_input = self.app.query_one("#hostname-input", Input)
|
hostname_input = self.app.query_one("#hostname-input", Input)
|
||||||
comment_input = self.app.query_one("#comment-input", Input)
|
comment_input = self.app.query_one("#comment-input", Input)
|
||||||
active_checkbox = self.app.query_one("#active-checkbox", Checkbox)
|
active_checkbox = self.app.query_one("#active-checkbox", Checkbox)
|
||||||
|
|
||||||
# Try to get DNS input - may not exist in all contexts
|
# Try to get DNS input - may not exist in all contexts
|
||||||
try:
|
try:
|
||||||
dns_input = self.app.query_one("#dns-name-input", Input)
|
dns_input = self.app.query_one("#dns-name-input", Input)
|
||||||
|
|
@ -237,7 +240,7 @@ class EditHandler:
|
||||||
dns_name = self.app.original_entry_values.get("dns_name")
|
dns_name = self.app.original_entry_values.get("dns_name")
|
||||||
ip_radio = self.app.query_one("#edit-ip-entry-radio")
|
ip_radio = self.app.query_one("#edit-ip-entry-radio")
|
||||||
dns_radio = self.app.query_one("#edit-dns-entry-radio")
|
dns_radio = self.app.query_one("#edit-dns-entry-radio")
|
||||||
|
|
||||||
if dns_name:
|
if dns_name:
|
||||||
# Was DNS entry - set DNS radio and show DNS field
|
# Was DNS entry - set DNS radio and show DNS field
|
||||||
ip_radio.value = False
|
ip_radio.value = False
|
||||||
|
|
@ -254,11 +257,13 @@ class EditHandler:
|
||||||
def validate_entry_by_type(self, entry_type: str) -> bool:
|
def validate_entry_by_type(self, entry_type: str) -> bool:
|
||||||
"""Type-specific validation for IP or DNS entries."""
|
"""Type-specific validation for IP or DNS entries."""
|
||||||
hostname_input = self.app.query_one("#hostname-input", Input)
|
hostname_input = self.app.query_one("#hostname-input", Input)
|
||||||
|
|
||||||
# Validate hostname(s) - common to both types
|
# Validate hostname(s) - common to both types
|
||||||
hostnames = [h.strip() for h in hostname_input.value.split(",") if h.strip()]
|
hostnames = [h.strip() for h in hostname_input.value.split(",") if h.strip()]
|
||||||
if not hostnames:
|
if not hostnames:
|
||||||
self.app.update_status("❌ At least one hostname is required - changes not saved")
|
self.app.update_status(
|
||||||
|
"❌ At least one hostname is required - changes not saved"
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
hostname_pattern = re.compile(
|
hostname_pattern = re.compile(
|
||||||
|
|
@ -267,16 +272,20 @@ class EditHandler:
|
||||||
|
|
||||||
for hostname in hostnames:
|
for hostname in hostnames:
|
||||||
if not hostname_pattern.match(hostname):
|
if not hostname_pattern.match(hostname):
|
||||||
self.app.update_status(f"❌ Invalid hostname: {hostname} - changes not saved")
|
self.app.update_status(
|
||||||
|
f"❌ Invalid hostname: {hostname} - changes not saved"
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if entry_type == "ip":
|
if entry_type == "ip":
|
||||||
# Validate IP address
|
# Validate IP address
|
||||||
try:
|
try:
|
||||||
ip_input = self.app.query_one("#ip-input", Input)
|
ip_input = self.app.query_one("#ip-input", Input)
|
||||||
ip_address = ip_input.value.strip()
|
ip_address = ip_input.value.strip()
|
||||||
if not ip_address:
|
if not ip_address:
|
||||||
self.app.update_status("❌ IP address is required - changes not saved")
|
self.app.update_status(
|
||||||
|
"❌ IP address is required - changes not saved"
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
ipaddress.ip_address(ip_address)
|
ipaddress.ip_address(ip_address)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
|
@ -288,9 +297,11 @@ class EditHandler:
|
||||||
dns_input = self.app.query_one("#dns-name-input", Input)
|
dns_input = self.app.query_one("#dns-name-input", Input)
|
||||||
dns_name = dns_input.value.strip()
|
dns_name = dns_input.value.strip()
|
||||||
if not dns_name:
|
if not dns_name:
|
||||||
self.app.update_status("❌ DNS name is required - changes not saved")
|
self.app.update_status(
|
||||||
|
"❌ DNS name is required - changes not saved"
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Basic DNS name validation
|
# Basic DNS name validation
|
||||||
if (
|
if (
|
||||||
" " in dns_name
|
" " in dns_name
|
||||||
|
|
@ -299,12 +310,16 @@ class EditHandler:
|
||||||
or dns_name.endswith(".")
|
or dns_name.endswith(".")
|
||||||
or ".." in dns_name
|
or ".." in dns_name
|
||||||
):
|
):
|
||||||
self.app.update_status("❌ Invalid DNS name format - changes not saved")
|
self.app.update_status(
|
||||||
|
"❌ Invalid DNS name format - changes not saved"
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
except Exception:
|
except Exception:
|
||||||
self.app.update_status("❌ DNS name validation failed - changes not saved")
|
self.app.update_status(
|
||||||
|
"❌ DNS name validation failed - changes not saved"
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def validate_and_save_entry_changes(self) -> bool:
|
def validate_and_save_entry_changes(self) -> bool:
|
||||||
|
|
@ -348,11 +363,11 @@ class EditHandler:
|
||||||
entry.ip_address = ip_input.value.strip()
|
entry.ip_address = ip_input.value.strip()
|
||||||
entry.dns_name = None # Clear DNS name when converting to IP
|
entry.dns_name = None # Clear DNS name when converting to IP
|
||||||
# Clear DNS-related fields
|
# Clear DNS-related fields
|
||||||
if hasattr(entry, 'resolved_ip'):
|
if hasattr(entry, "resolved_ip"):
|
||||||
entry.resolved_ip = None
|
entry.resolved_ip = None
|
||||||
if hasattr(entry, 'last_resolved'):
|
if hasattr(entry, "last_resolved"):
|
||||||
entry.last_resolved = None
|
entry.last_resolved = None
|
||||||
if hasattr(entry, 'dns_resolution_status'):
|
if hasattr(entry, "dns_resolution_status"):
|
||||||
entry.dns_resolution_status = None
|
entry.dns_resolution_status = None
|
||||||
else:
|
else:
|
||||||
# DNS entry - update DNS name and set placeholder IP
|
# DNS entry - update DNS name and set placeholder IP
|
||||||
|
|
@ -360,12 +375,13 @@ class EditHandler:
|
||||||
entry.dns_name = dns_input.value.strip()
|
entry.dns_name = dns_input.value.strip()
|
||||||
entry.ip_address = "0.0.0.0" # Placeholder IP for DNS entries
|
entry.ip_address = "0.0.0.0" # Placeholder IP for DNS entries
|
||||||
# Initialize DNS fields if they don't exist
|
# Initialize DNS fields if they don't exist
|
||||||
if not hasattr(entry, 'resolved_ip'):
|
if not hasattr(entry, "resolved_ip"):
|
||||||
entry.resolved_ip = None
|
entry.resolved_ip = None
|
||||||
if not hasattr(entry, 'last_resolved'):
|
if not hasattr(entry, "last_resolved"):
|
||||||
entry.last_resolved = None
|
entry.last_resolved = None
|
||||||
if not hasattr(entry, 'dns_resolution_status'):
|
if not hasattr(entry, "dns_resolution_status"):
|
||||||
from ..core.dns import DNSResolutionStatus
|
from ..core.dns import DNSResolutionStatus
|
||||||
|
|
||||||
entry.dns_resolution_status = DNSResolutionStatus.NOT_RESOLVED
|
entry.dns_resolution_status = DNSResolutionStatus.NOT_RESOLVED
|
||||||
|
|
||||||
# Update common fields
|
# Update common fields
|
||||||
|
|
@ -385,10 +401,12 @@ class EditHandler:
|
||||||
)
|
)
|
||||||
if table.row_count > 0 and display_index < table.row_count:
|
if table.row_count > 0 and display_index < table.row_count:
|
||||||
table.move_cursor(row=display_index)
|
table.move_cursor(row=display_index)
|
||||||
|
|
||||||
# Provide appropriate success message
|
# Provide appropriate success message
|
||||||
if entry_type == "dns":
|
if entry_type == "dns":
|
||||||
self.app.update_status("DNS entry saved successfully - DNS resolution can be triggered manually")
|
self.app.update_status(
|
||||||
|
"DNS entry saved successfully - DNS resolution can be triggered manually"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.app.update_status("Entry saved successfully")
|
self.app.update_status("Entry saved successfully")
|
||||||
return True
|
return True
|
||||||
|
|
@ -407,10 +425,10 @@ class EditHandler:
|
||||||
hostname_input = self.app.query_one("#hostname-input", Input)
|
hostname_input = self.app.query_one("#hostname-input", Input)
|
||||||
comment_input = self.app.query_one("#comment-input", Input)
|
comment_input = self.app.query_one("#comment-input", Input)
|
||||||
active_checkbox = self.app.query_one("#active-checkbox", Checkbox)
|
active_checkbox = self.app.query_one("#active-checkbox", Checkbox)
|
||||||
|
|
||||||
# Build field list based on current entry type
|
# Build field list based on current entry type
|
||||||
fields = [radio_set]
|
fields = [radio_set]
|
||||||
|
|
||||||
# Add IP or DNS field based on visibility
|
# Add IP or DNS field based on visibility
|
||||||
try:
|
try:
|
||||||
ip_section = self.app.query_one("#edit-ip-section")
|
ip_section = self.app.query_one("#edit-ip-section")
|
||||||
|
|
@ -419,7 +437,7 @@ class EditHandler:
|
||||||
fields.append(ip_input)
|
fields.append(ip_input)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dns_section = self.app.query_one("#edit-dns-section")
|
dns_section = self.app.query_one("#edit-dns-section")
|
||||||
if not dns_section.has_class("hidden"):
|
if not dns_section.has_class("hidden"):
|
||||||
|
|
@ -427,7 +445,7 @@ class EditHandler:
|
||||||
fields.append(dns_input)
|
fields.append(dns_input)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Add remaining fields
|
# Add remaining fields
|
||||||
fields.extend([hostname_input, comment_input, active_checkbox])
|
fields.extend([hostname_input, comment_input, active_checkbox])
|
||||||
|
|
||||||
|
|
@ -437,7 +455,7 @@ class EditHandler:
|
||||||
next_field = fields[(i + 1) % len(fields)]
|
next_field = fields[(i + 1) % len(fields)]
|
||||||
next_field.focus()
|
next_field.focus()
|
||||||
break
|
break
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Fallback to original navigation if widgets not ready
|
# Fallback to original navigation if widgets not ready
|
||||||
pass
|
pass
|
||||||
|
|
@ -453,10 +471,10 @@ class EditHandler:
|
||||||
hostname_input = self.app.query_one("#hostname-input", Input)
|
hostname_input = self.app.query_one("#hostname-input", Input)
|
||||||
comment_input = self.app.query_one("#comment-input", Input)
|
comment_input = self.app.query_one("#comment-input", Input)
|
||||||
active_checkbox = self.app.query_one("#active-checkbox", Checkbox)
|
active_checkbox = self.app.query_one("#active-checkbox", Checkbox)
|
||||||
|
|
||||||
# Build field list based on current entry type
|
# Build field list based on current entry type
|
||||||
fields = [radio_set]
|
fields = [radio_set]
|
||||||
|
|
||||||
# Add IP or DNS field based on visibility
|
# Add IP or DNS field based on visibility
|
||||||
try:
|
try:
|
||||||
ip_section = self.app.query_one("#edit-ip-section")
|
ip_section = self.app.query_one("#edit-ip-section")
|
||||||
|
|
@ -465,7 +483,7 @@ class EditHandler:
|
||||||
fields.append(ip_input)
|
fields.append(ip_input)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dns_section = self.app.query_one("#edit-dns-section")
|
dns_section = self.app.query_one("#edit-dns-section")
|
||||||
if not dns_section.has_class("hidden"):
|
if not dns_section.has_class("hidden"):
|
||||||
|
|
@ -473,7 +491,7 @@ class EditHandler:
|
||||||
fields.append(dns_input)
|
fields.append(dns_input)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Add remaining fields
|
# Add remaining fields
|
||||||
fields.extend([hostname_input, comment_input, active_checkbox])
|
fields.extend([hostname_input, comment_input, active_checkbox])
|
||||||
|
|
||||||
|
|
@ -483,7 +501,7 @@ class EditHandler:
|
||||||
prev_field = fields[(i - 1) % len(fields)]
|
prev_field = fields[(i - 1) % len(fields)]
|
||||||
prev_field.focus()
|
prev_field.focus()
|
||||||
break
|
break
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Fallback to original navigation if widgets not ready
|
# Fallback to original navigation if widgets not ready
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,15 @@ filtering options including status, type, resolution status, and search filterin
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
from textual.containers import Grid, Horizontal, Container
|
from textual.containers import Grid, Horizontal, Container
|
||||||
from textual.widgets import (
|
from textual.widgets import (
|
||||||
Static, Button, Checkbox, Input, Select, Label,
|
Static,
|
||||||
RadioSet, RadioButton, Collapsible
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Label,
|
||||||
|
RadioSet,
|
||||||
|
RadioButton,
|
||||||
|
Collapsible,
|
||||||
)
|
)
|
||||||
from textual.screen import ModalScreen
|
from textual.screen import ModalScreen
|
||||||
from textual.reactive import reactive
|
from textual.reactive import reactive
|
||||||
|
|
@ -21,7 +28,7 @@ from ..core.filters import FilterOptions, EntryFilter
|
||||||
|
|
||||||
class FilterModal(ModalScreen[Optional[FilterOptions]]):
|
class FilterModal(ModalScreen[Optional[FilterOptions]]):
|
||||||
"""Advanced filtering configuration modal."""
|
"""Advanced filtering configuration modal."""
|
||||||
|
|
||||||
DEFAULT_CSS = """
|
DEFAULT_CSS = """
|
||||||
FilterModal {
|
FilterModal {
|
||||||
align: center middle;
|
align: center middle;
|
||||||
|
|
@ -140,17 +147,20 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
|
||||||
margin: 1 0;
|
margin: 1 0;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Reactive properties for real-time updates
|
# Reactive properties for real-time updates
|
||||||
current_options: reactive[FilterOptions] = reactive(FilterOptions())
|
current_options: reactive[FilterOptions] = reactive(FilterOptions())
|
||||||
entry_counts: reactive[Dict[str, int]] = reactive({})
|
entry_counts: reactive[Dict[str, int]] = reactive({})
|
||||||
|
|
||||||
def __init__(self, initial_options: Optional[FilterOptions] = None,
|
def __init__(
|
||||||
entries: Optional[List] = None,
|
self,
|
||||||
entry_filter: Optional[EntryFilter] = None):
|
initial_options: Optional[FilterOptions] = None,
|
||||||
|
entries: Optional[List] = None,
|
||||||
|
entry_filter: Optional[EntryFilter] = None,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Initialize filter modal.
|
Initialize filter modal.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
initial_options: Current filter options to display
|
initial_options: Current filter options to display
|
||||||
entries: List of entries for count preview
|
entries: List of entries for count preview
|
||||||
|
|
@ -161,12 +171,12 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
|
||||||
self.entries = entries or []
|
self.entries = entries or []
|
||||||
self.entry_filter = entry_filter or EntryFilter()
|
self.entry_filter = entry_filter or EntryFilter()
|
||||||
self.entry_counts = self._calculate_counts()
|
self.entry_counts = self._calculate_counts()
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
"""Compose the filter modal interface."""
|
"""Compose the filter modal interface."""
|
||||||
with Grid(id="filter-dialog"):
|
with Grid(id="filter-dialog"):
|
||||||
yield Static("Advanced Filtering", id="filter-header")
|
yield Static("Advanced Filtering", id="filter-header")
|
||||||
|
|
||||||
with Container(id="filter-content"):
|
with Container(id="filter-content"):
|
||||||
# Filter presets section
|
# Filter presets section
|
||||||
with Collapsible(title="Filter Presets", collapsed=False):
|
with Collapsible(title="Filter Presets", collapsed=False):
|
||||||
|
|
@ -174,97 +184,159 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
|
||||||
with Horizontal(classes="preset-row"):
|
with Horizontal(classes="preset-row"):
|
||||||
yield Label("Preset:", classes="filter-input-label")
|
yield Label("Preset:", classes="filter-input-label")
|
||||||
yield Select(
|
yield Select(
|
||||||
[(name, name) for name in self.entry_filter.get_preset_names()],
|
[
|
||||||
|
(name, name)
|
||||||
|
for name in self.entry_filter.get_preset_names()
|
||||||
|
],
|
||||||
value=self.current_options.preset_name,
|
value=self.current_options.preset_name,
|
||||||
id="preset-select",
|
id="preset-select",
|
||||||
classes="preset-select"
|
classes="preset-select",
|
||||||
)
|
)
|
||||||
yield Button("Load", id="load-preset", variant="primary")
|
yield Button("Load", id="load-preset", variant="primary")
|
||||||
yield Button("Save", id="save-preset")
|
yield Button("Save", id="save-preset")
|
||||||
yield Button("Delete", id="delete-preset", variant="error")
|
yield Button("Delete", id="delete-preset", variant="error")
|
||||||
|
|
||||||
# Status filtering section
|
# Status filtering section
|
||||||
with Collapsible(title="Status Filtering", collapsed=False):
|
with Collapsible(title="Status Filtering", collapsed=False):
|
||||||
with Container(classes="filter-section"):
|
with Container(classes="filter-section"):
|
||||||
yield Static("Status Filtering", classes="filter-section-title")
|
yield Static("Status Filtering", classes="filter-section-title")
|
||||||
with RadioSet(id="status-filter-type"):
|
with RadioSet(id="status-filter-type"):
|
||||||
yield RadioButton("Show All", value="all", id="status-all")
|
yield RadioButton("Show All", value="all", id="status-all")
|
||||||
yield RadioButton("Active Only", value="active", id="status-active")
|
yield RadioButton(
|
||||||
yield RadioButton("Inactive Only", value="inactive", id="status-inactive")
|
"Active Only", value="active", id="status-active"
|
||||||
yield RadioButton("Custom", value="custom", id="status-custom")
|
)
|
||||||
|
yield RadioButton(
|
||||||
with Container(classes="filter-checkboxes", id="status-custom-options"):
|
"Inactive Only", value="inactive", id="status-inactive"
|
||||||
yield Checkbox("Show Active Entries", value=True, id="show-active")
|
)
|
||||||
yield Checkbox("Show Inactive Entries", value=True, id="show-inactive")
|
yield RadioButton(
|
||||||
|
"Custom", value="custom", id="status-custom"
|
||||||
|
)
|
||||||
|
|
||||||
|
with Container(
|
||||||
|
classes="filter-checkboxes", id="status-custom-options"
|
||||||
|
):
|
||||||
|
yield Checkbox(
|
||||||
|
"Show Active Entries", value=True, id="show-active"
|
||||||
|
)
|
||||||
|
yield Checkbox(
|
||||||
|
"Show Inactive Entries", value=True, id="show-inactive"
|
||||||
|
)
|
||||||
|
|
||||||
# DNS type filtering section
|
# DNS type filtering section
|
||||||
with Collapsible(title="Entry Type Filtering", collapsed=False):
|
with Collapsible(title="Entry Type Filtering", collapsed=False):
|
||||||
with Container(classes="filter-section"):
|
with Container(classes="filter-section"):
|
||||||
yield Static("Entry Type Filtering", classes="filter-section-title")
|
yield Static(
|
||||||
|
"Entry Type Filtering", classes="filter-section-title"
|
||||||
|
)
|
||||||
with RadioSet(id="type-filter-type"):
|
with RadioSet(id="type-filter-type"):
|
||||||
yield RadioButton("Show All", value="all", id="type-all")
|
yield RadioButton("Show All", value="all", id="type-all")
|
||||||
yield RadioButton("DNS Entries Only", value="dns", id="type-dns")
|
yield RadioButton(
|
||||||
yield RadioButton("IP Entries Only", value="ip", id="type-ip")
|
"DNS Entries Only", value="dns", id="type-dns"
|
||||||
yield RadioButton("Custom", value="custom", id="type-custom")
|
)
|
||||||
|
yield RadioButton(
|
||||||
with Container(classes="filter-checkboxes", id="type-custom-options"):
|
"IP Entries Only", value="ip", id="type-ip"
|
||||||
yield Checkbox("Show DNS Entries", value=True, id="show-dns")
|
)
|
||||||
|
yield RadioButton(
|
||||||
|
"Custom", value="custom", id="type-custom"
|
||||||
|
)
|
||||||
|
|
||||||
|
with Container(
|
||||||
|
classes="filter-checkboxes", id="type-custom-options"
|
||||||
|
):
|
||||||
|
yield Checkbox(
|
||||||
|
"Show DNS Entries", value=True, id="show-dns"
|
||||||
|
)
|
||||||
yield Checkbox("Show IP Entries", value=True, id="show-ip")
|
yield Checkbox("Show IP Entries", value=True, id="show-ip")
|
||||||
|
|
||||||
# DNS resolution status filtering section
|
# DNS resolution status filtering section
|
||||||
with Collapsible(title="Resolution Status Filtering", collapsed=False):
|
with Collapsible(title="Resolution Status Filtering", collapsed=False):
|
||||||
with Container(classes="filter-section"):
|
with Container(classes="filter-section"):
|
||||||
yield Static("Resolution Status Filtering", classes="filter-section-title")
|
yield Static(
|
||||||
|
"Resolution Status Filtering",
|
||||||
|
classes="filter-section-title",
|
||||||
|
)
|
||||||
with RadioSet(id="resolution-filter-type"):
|
with RadioSet(id="resolution-filter-type"):
|
||||||
yield RadioButton("Show All", value="all", id="resolution-all")
|
yield RadioButton(
|
||||||
yield RadioButton("Resolved Only", value="resolved", id="resolution-resolved")
|
"Show All", value="all", id="resolution-all"
|
||||||
yield RadioButton("Mismatches Only", value="mismatch", id="resolution-mismatch")
|
)
|
||||||
yield RadioButton("Custom", value="custom", id="resolution-custom")
|
yield RadioButton(
|
||||||
|
"Resolved Only",
|
||||||
with Container(classes="filter-checkboxes", id="resolution-custom-options"):
|
value="resolved",
|
||||||
yield Checkbox("Show Resolved", value=True, id="show-resolved")
|
id="resolution-resolved",
|
||||||
yield Checkbox("Show Unresolved", value=True, id="show-unresolved")
|
)
|
||||||
yield Checkbox("Show Resolving", value=True, id="show-resolving")
|
yield RadioButton(
|
||||||
|
"Mismatches Only",
|
||||||
|
value="mismatch",
|
||||||
|
id="resolution-mismatch",
|
||||||
|
)
|
||||||
|
yield RadioButton(
|
||||||
|
"Custom", value="custom", id="resolution-custom"
|
||||||
|
)
|
||||||
|
|
||||||
|
with Container(
|
||||||
|
classes="filter-checkboxes", id="resolution-custom-options"
|
||||||
|
):
|
||||||
|
yield Checkbox(
|
||||||
|
"Show Resolved", value=True, id="show-resolved"
|
||||||
|
)
|
||||||
|
yield Checkbox(
|
||||||
|
"Show Unresolved", value=True, id="show-unresolved"
|
||||||
|
)
|
||||||
|
yield Checkbox(
|
||||||
|
"Show Resolving", value=True, id="show-resolving"
|
||||||
|
)
|
||||||
yield Checkbox("Show Failed", value=True, id="show-failed")
|
yield Checkbox("Show Failed", value=True, id="show-failed")
|
||||||
yield Checkbox("Show Mismatched", value=True, id="show-mismatched")
|
yield Checkbox(
|
||||||
|
"Show Mismatched", value=True, id="show-mismatched"
|
||||||
|
)
|
||||||
|
|
||||||
# Search filtering section
|
# Search filtering section
|
||||||
with Collapsible(title="Search Filtering", collapsed=True):
|
with Collapsible(title="Search Filtering", collapsed=True):
|
||||||
with Container(classes="filter-section"):
|
with Container(classes="filter-section"):
|
||||||
yield Static("Search Filtering", classes="filter-section-title")
|
yield Static("Search Filtering", classes="filter-section-title")
|
||||||
|
|
||||||
with Horizontal(classes="filter-input-row"):
|
with Horizontal(classes="filter-input-row"):
|
||||||
yield Label("Search term:", classes="filter-input-label")
|
yield Label("Search term:", classes="filter-input-label")
|
||||||
yield Input(
|
yield Input(
|
||||||
placeholder="Enter search term...",
|
placeholder="Enter search term...",
|
||||||
value=self.current_options.search_term or "",
|
value=self.current_options.search_term or "",
|
||||||
id="search-term",
|
id="search-term",
|
||||||
classes="filter-input"
|
classes="filter-input",
|
||||||
)
|
)
|
||||||
|
|
||||||
with Container(classes="filter-checkboxes"):
|
with Container(classes="filter-checkboxes"):
|
||||||
yield Checkbox("Search in hostnames", value=True, id="search-hostnames")
|
yield Checkbox(
|
||||||
yield Checkbox("Search in comments", value=True, id="search-comments")
|
"Search in hostnames", value=True, id="search-hostnames"
|
||||||
yield Checkbox("Search in IP addresses", value=True, id="search-ips")
|
)
|
||||||
yield Checkbox("Case sensitive", value=False, id="search-case-sensitive")
|
yield Checkbox(
|
||||||
|
"Search in comments", value=True, id="search-comments"
|
||||||
|
)
|
||||||
|
yield Checkbox(
|
||||||
|
"Search in IP addresses", value=True, id="search-ips"
|
||||||
|
)
|
||||||
|
yield Checkbox(
|
||||||
|
"Case sensitive",
|
||||||
|
value=False,
|
||||||
|
id="search-case-sensitive",
|
||||||
|
)
|
||||||
|
|
||||||
# Entry count display
|
# Entry count display
|
||||||
yield Static("", id="count-display", classes="count-display")
|
yield Static("", id="count-display", classes="count-display")
|
||||||
|
|
||||||
with Horizontal(id="filter-actions"):
|
with Horizontal(id="filter-actions"):
|
||||||
yield Button("Apply", id="apply", variant="primary")
|
yield Button("Apply", id="apply", variant="primary")
|
||||||
yield Button("Reset", id="reset")
|
yield Button("Reset", id="reset")
|
||||||
yield Button("Cancel", id="cancel")
|
yield Button("Cancel", id="cancel")
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
"""Initialize the modal with current options."""
|
"""Initialize the modal with current options."""
|
||||||
self._update_ui_from_options()
|
self._update_ui_from_options()
|
||||||
self._update_count_display()
|
self._update_count_display()
|
||||||
|
|
||||||
def _update_ui_from_options(self) -> None:
|
def _update_ui_from_options(self) -> None:
|
||||||
"""Update UI controls to reflect current options."""
|
"""Update UI controls to reflect current options."""
|
||||||
options = self.current_options
|
options = self.current_options
|
||||||
|
|
||||||
# Status filtering
|
# Status filtering
|
||||||
if options.active_only:
|
if options.active_only:
|
||||||
self.query_one("#status-active", RadioButton).value = True
|
self.query_one("#status-active", RadioButton).value = True
|
||||||
|
|
@ -274,10 +346,10 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
|
||||||
self.query_one("#status-all", RadioButton).value = True
|
self.query_one("#status-all", RadioButton).value = True
|
||||||
else:
|
else:
|
||||||
self.query_one("#status-custom", RadioButton).value = True
|
self.query_one("#status-custom", RadioButton).value = True
|
||||||
|
|
||||||
self.query_one("#show-active", Checkbox).value = options.show_active
|
self.query_one("#show-active", Checkbox).value = options.show_active
|
||||||
self.query_one("#show-inactive", Checkbox).value = options.show_inactive
|
self.query_one("#show-inactive", Checkbox).value = options.show_inactive
|
||||||
|
|
||||||
# Type filtering
|
# Type filtering
|
||||||
if options.dns_only:
|
if options.dns_only:
|
||||||
self.query_one("#type-dns", RadioButton).value = True
|
self.query_one("#type-dns", RadioButton).value = True
|
||||||
|
|
@ -287,60 +359,71 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
|
||||||
self.query_one("#type-all", RadioButton).value = True
|
self.query_one("#type-all", RadioButton).value = True
|
||||||
else:
|
else:
|
||||||
self.query_one("#type-custom", RadioButton).value = True
|
self.query_one("#type-custom", RadioButton).value = True
|
||||||
|
|
||||||
self.query_one("#show-dns", Checkbox).value = options.show_dns_entries
|
self.query_one("#show-dns", Checkbox).value = options.show_dns_entries
|
||||||
self.query_one("#show-ip", Checkbox).value = options.show_ip_entries
|
self.query_one("#show-ip", Checkbox).value = options.show_ip_entries
|
||||||
|
|
||||||
# Resolution status filtering
|
# Resolution status filtering
|
||||||
if options.resolved_only:
|
if options.resolved_only:
|
||||||
self.query_one("#resolution-resolved", RadioButton).value = True
|
self.query_one("#resolution-resolved", RadioButton).value = True
|
||||||
elif options.mismatch_only:
|
elif options.mismatch_only:
|
||||||
self.query_one("#resolution-mismatch", RadioButton).value = True
|
self.query_one("#resolution-mismatch", RadioButton).value = True
|
||||||
elif (options.show_resolved and options.show_unresolved and
|
elif (
|
||||||
options.show_resolving and options.show_failed and options.show_mismatched):
|
options.show_resolved
|
||||||
|
and options.show_unresolved
|
||||||
|
and options.show_resolving
|
||||||
|
and options.show_failed
|
||||||
|
and options.show_mismatched
|
||||||
|
):
|
||||||
self.query_one("#resolution-all", RadioButton).value = True
|
self.query_one("#resolution-all", RadioButton).value = True
|
||||||
else:
|
else:
|
||||||
self.query_one("#resolution-custom", RadioButton).value = True
|
self.query_one("#resolution-custom", RadioButton).value = True
|
||||||
|
|
||||||
self.query_one("#show-resolved", Checkbox).value = options.show_resolved
|
self.query_one("#show-resolved", Checkbox).value = options.show_resolved
|
||||||
self.query_one("#show-unresolved", Checkbox).value = options.show_unresolved
|
self.query_one("#show-unresolved", Checkbox).value = options.show_unresolved
|
||||||
self.query_one("#show-resolving", Checkbox).value = options.show_resolving
|
self.query_one("#show-resolving", Checkbox).value = options.show_resolving
|
||||||
self.query_one("#show-failed", Checkbox).value = options.show_failed
|
self.query_one("#show-failed", Checkbox).value = options.show_failed
|
||||||
self.query_one("#show-mismatched", Checkbox).value = options.show_mismatched
|
self.query_one("#show-mismatched", Checkbox).value = options.show_mismatched
|
||||||
|
|
||||||
# Search filtering
|
# Search filtering
|
||||||
if options.search_term:
|
if options.search_term:
|
||||||
self.query_one("#search-term", Input).value = options.search_term
|
self.query_one("#search-term", Input).value = options.search_term
|
||||||
self.query_one("#search-hostnames", Checkbox).value = options.search_in_hostnames
|
self.query_one(
|
||||||
|
"#search-hostnames", Checkbox
|
||||||
|
).value = options.search_in_hostnames
|
||||||
self.query_one("#search-comments", Checkbox).value = options.search_in_comments
|
self.query_one("#search-comments", Checkbox).value = options.search_in_comments
|
||||||
self.query_one("#search-ips", Checkbox).value = options.search_in_ips
|
self.query_one("#search-ips", Checkbox).value = options.search_in_ips
|
||||||
self.query_one("#search-case-sensitive", Checkbox).value = options.case_sensitive
|
self.query_one(
|
||||||
|
"#search-case-sensitive", Checkbox
|
||||||
|
).value = options.case_sensitive
|
||||||
|
|
||||||
self._update_custom_options_visibility()
|
self._update_custom_options_visibility()
|
||||||
|
|
||||||
def _update_custom_options_visibility(self) -> None:
|
def _update_custom_options_visibility(self) -> None:
|
||||||
"""Show/hide custom option containers based on radio selections."""
|
"""Show/hide custom option containers based on radio selections."""
|
||||||
# Status custom options
|
# Status custom options
|
||||||
status_custom = self.query_one("#status-custom", RadioButton).value
|
status_custom = self.query_one("#status-custom", RadioButton).value
|
||||||
status_container = self.query_one("#status-custom-options")
|
status_container = self.query_one("#status-custom-options")
|
||||||
status_container.display = status_custom
|
status_container.display = status_custom
|
||||||
|
|
||||||
# Type custom options
|
# Type custom options
|
||||||
type_custom = self.query_one("#type-custom", RadioButton).value
|
type_custom = self.query_one("#type-custom", RadioButton).value
|
||||||
type_container = self.query_one("#type-custom-options")
|
type_container = self.query_one("#type-custom-options")
|
||||||
type_container.display = type_custom
|
type_container.display = type_custom
|
||||||
|
|
||||||
# Resolution custom options
|
# Resolution custom options
|
||||||
resolution_custom = self.query_one("#resolution-custom", RadioButton).value
|
resolution_custom = self.query_one("#resolution-custom", RadioButton).value
|
||||||
resolution_container = self.query_one("#resolution-custom-options")
|
resolution_container = self.query_one("#resolution-custom-options")
|
||||||
resolution_container.display = resolution_custom
|
resolution_container.display = resolution_custom
|
||||||
|
|
||||||
def _calculate_counts(self) -> Dict[str, int]:
|
def _calculate_counts(self) -> Dict[str, int]:
|
||||||
"""Calculate entry counts for current filter options."""
|
"""Calculate entry counts for current filter options."""
|
||||||
if not self.entries:
|
if not self.entries:
|
||||||
return {}
|
return {}
|
||||||
return self.entry_filter.count_filtered_entries(self.entries, self.current_options)
|
return self.entry_filter.count_filtered_entries(
|
||||||
|
self.entries, self.current_options
|
||||||
|
)
|
||||||
|
|
||||||
def _update_count_display(self) -> None:
|
def _update_count_display(self) -> None:
|
||||||
"""Update the count display with current filter results."""
|
"""Update the count display with current filter results."""
|
||||||
counts = self._calculate_counts()
|
counts = self._calculate_counts()
|
||||||
|
|
@ -351,9 +434,9 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
count_text = "No entries to filter"
|
count_text = "No entries to filter"
|
||||||
|
|
||||||
self.query_one("#count-display", Static).update(count_text)
|
self.query_one("#count-display", Static).update(count_text)
|
||||||
|
|
||||||
def _get_current_options_from_ui(self) -> FilterOptions:
|
def _get_current_options_from_ui(self) -> FilterOptions:
|
||||||
"""Extract current filter options from UI controls."""
|
"""Extract current filter options from UI controls."""
|
||||||
# Status filtering
|
# Status filtering
|
||||||
|
|
@ -371,7 +454,7 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
|
||||||
show_active = self.query_one("#show-active", Checkbox).value
|
show_active = self.query_one("#show-active", Checkbox).value
|
||||||
show_inactive = self.query_one("#show-inactive", Checkbox).value
|
show_inactive = self.query_one("#show-inactive", Checkbox).value
|
||||||
active_only, inactive_only = False, False
|
active_only, inactive_only = False, False
|
||||||
|
|
||||||
# Type filtering
|
# Type filtering
|
||||||
type_type = self.query_one("#type-filter-type", RadioSet).pressed_button
|
type_type = self.query_one("#type-filter-type", RadioSet).pressed_button
|
||||||
if type_type and type_type.id == "type-dns":
|
if type_type and type_type.id == "type-dns":
|
||||||
|
|
@ -387,18 +470,38 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
|
||||||
show_dns_entries = self.query_one("#show-dns", Checkbox).value
|
show_dns_entries = self.query_one("#show-dns", Checkbox).value
|
||||||
show_ip_entries = self.query_one("#show-ip", Checkbox).value
|
show_ip_entries = self.query_one("#show-ip", Checkbox).value
|
||||||
dns_only, ip_only = False, False
|
dns_only, ip_only = False, False
|
||||||
|
|
||||||
# Resolution status filtering
|
# Resolution status filtering
|
||||||
resolution_type = self.query_one("#resolution-filter-type", RadioSet).pressed_button
|
resolution_type = self.query_one(
|
||||||
|
"#resolution-filter-type", RadioSet
|
||||||
|
).pressed_button
|
||||||
if resolution_type and resolution_type.id == "resolution-resolved":
|
if resolution_type and resolution_type.id == "resolution-resolved":
|
||||||
resolved_only, mismatch_only = True, False
|
resolved_only, mismatch_only = True, False
|
||||||
show_resolved, show_unresolved, show_resolving, show_failed, show_mismatched = True, False, False, False, False
|
(
|
||||||
|
show_resolved,
|
||||||
|
show_unresolved,
|
||||||
|
show_resolving,
|
||||||
|
show_failed,
|
||||||
|
show_mismatched,
|
||||||
|
) = True, False, False, False, False
|
||||||
elif resolution_type and resolution_type.id == "resolution-mismatch":
|
elif resolution_type and resolution_type.id == "resolution-mismatch":
|
||||||
resolved_only, mismatch_only = False, True
|
resolved_only, mismatch_only = False, True
|
||||||
show_resolved, show_unresolved, show_resolving, show_failed, show_mismatched = False, False, False, False, True
|
(
|
||||||
|
show_resolved,
|
||||||
|
show_unresolved,
|
||||||
|
show_resolving,
|
||||||
|
show_failed,
|
||||||
|
show_mismatched,
|
||||||
|
) = False, False, False, False, True
|
||||||
elif resolution_type and resolution_type.id == "resolution-all":
|
elif resolution_type and resolution_type.id == "resolution-all":
|
||||||
resolved_only, mismatch_only = False, False
|
resolved_only, mismatch_only = False, False
|
||||||
show_resolved, show_unresolved, show_resolving, show_failed, show_mismatched = True, True, True, True, True
|
(
|
||||||
|
show_resolved,
|
||||||
|
show_unresolved,
|
||||||
|
show_resolving,
|
||||||
|
show_failed,
|
||||||
|
show_mismatched,
|
||||||
|
) = True, True, True, True, True
|
||||||
else: # custom
|
else: # custom
|
||||||
resolved_only, mismatch_only = False, False
|
resolved_only, mismatch_only = False, False
|
||||||
show_resolved = self.query_one("#show-resolved", Checkbox).value
|
show_resolved = self.query_one("#show-resolved", Checkbox).value
|
||||||
|
|
@ -406,14 +509,14 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
|
||||||
show_resolving = self.query_one("#show-resolving", Checkbox).value
|
show_resolving = self.query_one("#show-resolving", Checkbox).value
|
||||||
show_failed = self.query_one("#show-failed", Checkbox).value
|
show_failed = self.query_one("#show-failed", Checkbox).value
|
||||||
show_mismatched = self.query_one("#show-mismatched", Checkbox).value
|
show_mismatched = self.query_one("#show-mismatched", Checkbox).value
|
||||||
|
|
||||||
# Search filtering
|
# Search filtering
|
||||||
search_term = self.query_one("#search-term", Input).value or None
|
search_term = self.query_one("#search-term", Input).value or None
|
||||||
search_hostnames = self.query_one("#search-hostnames", Checkbox).value
|
search_hostnames = self.query_one("#search-hostnames", Checkbox).value
|
||||||
search_comments = self.query_one("#search-comments", Checkbox).value
|
search_comments = self.query_one("#search-comments", Checkbox).value
|
||||||
search_ips = self.query_one("#search-ips", Checkbox).value
|
search_ips = self.query_one("#search-ips", Checkbox).value
|
||||||
case_sensitive = self.query_one("#search-case-sensitive", Checkbox).value
|
case_sensitive = self.query_one("#search-case-sensitive", Checkbox).value
|
||||||
|
|
||||||
return FilterOptions(
|
return FilterOptions(
|
||||||
show_active=show_active,
|
show_active=show_active,
|
||||||
show_inactive=show_inactive,
|
show_inactive=show_inactive,
|
||||||
|
|
@ -434,40 +537,40 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
|
||||||
search_in_hostnames=search_hostnames,
|
search_in_hostnames=search_hostnames,
|
||||||
search_in_comments=search_comments,
|
search_in_comments=search_comments,
|
||||||
search_in_ips=search_ips,
|
search_in_ips=search_ips,
|
||||||
case_sensitive=case_sensitive
|
case_sensitive=case_sensitive,
|
||||||
)
|
)
|
||||||
|
|
||||||
@on(RadioSet.Changed)
|
@on(RadioSet.Changed)
|
||||||
def on_radio_changed(self, event: RadioSet.Changed) -> None:
|
def on_radio_changed(self, event: RadioSet.Changed) -> None:
|
||||||
"""Handle radio button changes."""
|
"""Handle radio button changes."""
|
||||||
self._update_custom_options_visibility()
|
self._update_custom_options_visibility()
|
||||||
self.current_options = self._get_current_options_from_ui()
|
self.current_options = self._get_current_options_from_ui()
|
||||||
self._update_count_display()
|
self._update_count_display()
|
||||||
|
|
||||||
@on(Checkbox.Changed)
|
@on(Checkbox.Changed)
|
||||||
@on(Input.Changed)
|
@on(Input.Changed)
|
||||||
def on_input_changed(self) -> None:
|
def on_input_changed(self) -> None:
|
||||||
"""Handle input changes for real-time preview."""
|
"""Handle input changes for real-time preview."""
|
||||||
self.current_options = self._get_current_options_from_ui()
|
self.current_options = self._get_current_options_from_ui()
|
||||||
self._update_count_display()
|
self._update_count_display()
|
||||||
|
|
||||||
@on(Button.Pressed, "#apply")
|
@on(Button.Pressed, "#apply")
|
||||||
def on_apply_pressed(self) -> None:
|
def on_apply_pressed(self) -> None:
|
||||||
"""Handle apply button press."""
|
"""Handle apply button press."""
|
||||||
self.dismiss(self._get_current_options_from_ui())
|
self.dismiss(self._get_current_options_from_ui())
|
||||||
|
|
||||||
@on(Button.Pressed, "#cancel")
|
@on(Button.Pressed, "#cancel")
|
||||||
def on_cancel_pressed(self) -> None:
|
def on_cancel_pressed(self) -> None:
|
||||||
"""Handle cancel button press."""
|
"""Handle cancel button press."""
|
||||||
self.dismiss(None)
|
self.dismiss(None)
|
||||||
|
|
||||||
@on(Button.Pressed, "#reset")
|
@on(Button.Pressed, "#reset")
|
||||||
def on_reset_pressed(self) -> None:
|
def on_reset_pressed(self) -> None:
|
||||||
"""Handle reset button press."""
|
"""Handle reset button press."""
|
||||||
self.current_options = FilterOptions()
|
self.current_options = FilterOptions()
|
||||||
self._update_ui_from_options()
|
self._update_ui_from_options()
|
||||||
self._update_count_display()
|
self._update_count_display()
|
||||||
|
|
||||||
@on(Button.Pressed, "#load-preset")
|
@on(Button.Pressed, "#load-preset")
|
||||||
def on_load_preset_pressed(self) -> None:
|
def on_load_preset_pressed(self) -> None:
|
||||||
"""Handle load preset button press."""
|
"""Handle load preset button press."""
|
||||||
|
|
@ -478,7 +581,7 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
|
||||||
self.current_options = preset_options
|
self.current_options = preset_options
|
||||||
self._update_ui_from_options()
|
self._update_ui_from_options()
|
||||||
self._update_count_display()
|
self._update_count_display()
|
||||||
|
|
||||||
@on(Button.Pressed, "#save-preset")
|
@on(Button.Pressed, "#save-preset")
|
||||||
def on_save_preset_pressed(self) -> None:
|
def on_save_preset_pressed(self) -> None:
|
||||||
"""Handle save preset button press."""
|
"""Handle save preset button press."""
|
||||||
|
|
@ -487,12 +590,14 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
|
||||||
current_options = self._get_current_options_from_ui()
|
current_options = self._get_current_options_from_ui()
|
||||||
preset_name = f"Custom Preset {len(self.entry_filter.presets) + 1}"
|
preset_name = f"Custom Preset {len(self.entry_filter.presets) + 1}"
|
||||||
self.entry_filter.save_preset(preset_name, current_options)
|
self.entry_filter.save_preset(preset_name, current_options)
|
||||||
|
|
||||||
# Update preset select with new preset
|
# Update preset select with new preset
|
||||||
preset_select = self.query_one("#preset-select", Select)
|
preset_select = self.query_one("#preset-select", Select)
|
||||||
preset_select.set_options([(name, name) for name in self.entry_filter.get_preset_names()])
|
preset_select.set_options(
|
||||||
|
[(name, name) for name in self.entry_filter.get_preset_names()]
|
||||||
|
)
|
||||||
preset_select.value = preset_name
|
preset_select.value = preset_name
|
||||||
|
|
||||||
@on(Button.Pressed, "#delete-preset")
|
@on(Button.Pressed, "#delete-preset")
|
||||||
def on_delete_preset_pressed(self) -> None:
|
def on_delete_preset_pressed(self) -> None:
|
||||||
"""Handle delete preset button press."""
|
"""Handle delete preset button press."""
|
||||||
|
|
@ -501,5 +606,7 @@ class FilterModal(ModalScreen[Optional[FilterOptions]]):
|
||||||
preset_name = str(preset_select.value)
|
preset_name = str(preset_select.value)
|
||||||
if self.entry_filter.delete_preset(preset_name):
|
if self.entry_filter.delete_preset(preset_name):
|
||||||
# Update preset select options
|
# Update preset select options
|
||||||
preset_select.set_options([(name, name) for name in self.entry_filter.get_preset_names()])
|
preset_select.set_options(
|
||||||
|
[(name, name) for name in self.entry_filter.get_preset_names()]
|
||||||
|
)
|
||||||
preset_select.value = Select.BLANK
|
preset_select.value = Select.BLANK
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,20 @@ HOSTS_MANAGER_BINDINGS = [
|
||||||
Binding("shift+down", "move_entry_down", "Move entry down", show=False),
|
Binding("shift+down", "move_entry_down", "Move entry down", show=False),
|
||||||
Binding("ctrl+z", "undo", "Undo", show=False, id="left:undo"),
|
Binding("ctrl+z", "undo", "Undo", show=False, id="left:undo"),
|
||||||
Binding("ctrl+y", "redo", "Redo", show=False, id="left:redo"),
|
Binding("ctrl+y", "redo", "Redo", show=False, id="left:redo"),
|
||||||
Binding("R", "refresh_dns", "Update all DNS based Entries", show=False, id="left:refresh_dns"),
|
Binding(
|
||||||
Binding("r", "update_single_dns", "Update selected DNS based Entry", show=False, id="left:update_single_dns"),
|
"R",
|
||||||
|
"refresh_dns",
|
||||||
|
"Update all DNS based Entries",
|
||||||
|
show=False,
|
||||||
|
id="left:refresh_dns",
|
||||||
|
),
|
||||||
|
Binding(
|
||||||
|
"r",
|
||||||
|
"update_single_dns",
|
||||||
|
"Update selected DNS based Entry",
|
||||||
|
show=False,
|
||||||
|
id="left:update_single_dns",
|
||||||
|
),
|
||||||
Binding("escape", "exit_edit_entry", "Exit edit mode", show=False),
|
Binding("escape", "exit_edit_entry", "Exit edit mode", show=False),
|
||||||
Binding("tab", "next_field", "Next field", show=False),
|
Binding("tab", "next_field", "Next field", show=False),
|
||||||
Binding("shift+tab", "prev_field", "Previous field", show=False),
|
Binding("shift+tab", "prev_field", "Previous field", show=False),
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,9 @@ class NavigationHandler:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
self.app.details_handler.update_entry_details()
|
self.app.details_handler.update_entry_details()
|
||||||
self.app.update_status(f"{result.message} - Changes saved automatically")
|
self.app.update_status(
|
||||||
|
f"{result.message} - Changes saved automatically"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.app.update_status(f"Entry toggled but save failed: {save_message}")
|
self.app.update_status(f"Entry toggled but save failed: {save_message}")
|
||||||
else:
|
else:
|
||||||
|
|
@ -89,7 +91,9 @@ class NavigationHandler:
|
||||||
if table.row_count > 0 and display_index < table.row_count:
|
if table.row_count > 0 and display_index < table.row_count:
|
||||||
table.move_cursor(row=display_index)
|
table.move_cursor(row=display_index)
|
||||||
self.app.details_handler.update_entry_details()
|
self.app.details_handler.update_entry_details()
|
||||||
self.app.update_status(f"{result.message} - Changes saved automatically")
|
self.app.update_status(
|
||||||
|
f"{result.message} - Changes saved automatically"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.app.update_status(f"Entry moved but save failed: {save_message}")
|
self.app.update_status(f"Entry moved but save failed: {save_message}")
|
||||||
else:
|
else:
|
||||||
|
|
@ -129,7 +133,9 @@ class NavigationHandler:
|
||||||
if table.row_count > 0 and display_index < table.row_count:
|
if table.row_count > 0 and display_index < table.row_count:
|
||||||
table.move_cursor(row=display_index)
|
table.move_cursor(row=display_index)
|
||||||
self.app.details_handler.update_entry_details()
|
self.app.details_handler.update_entry_details()
|
||||||
self.app.update_status(f"{result.message} - Changes saved automatically")
|
self.app.update_status(
|
||||||
|
f"{result.message} - Changes saved automatically"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.app.update_status(f"Entry moved but save failed: {save_message}")
|
self.app.update_status(f"Entry moved but save failed: {save_message}")
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,12 @@ class TableHandler:
|
||||||
all_entries.append(entry)
|
all_entries.append(entry)
|
||||||
|
|
||||||
# Apply advanced filtering if enabled
|
# Apply advanced filtering if enabled
|
||||||
if hasattr(self.app, 'entry_filter') and hasattr(self.app, 'current_filter_options'):
|
if hasattr(self.app, "entry_filter") and hasattr(
|
||||||
filtered_entries = self.app.entry_filter.apply_filters(all_entries, self.app.current_filter_options)
|
self.app, "current_filter_options"
|
||||||
|
):
|
||||||
|
filtered_entries = self.app.entry_filter.apply_filters(
|
||||||
|
all_entries, self.app.current_filter_options
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Fallback to legacy search filtering for backward compatibility
|
# Fallback to legacy search filtering for backward compatibility
|
||||||
filtered_entries = self._apply_legacy_search_filter(all_entries)
|
filtered_entries = self._apply_legacy_search_filter(all_entries)
|
||||||
|
|
@ -45,7 +49,7 @@ class TableHandler:
|
||||||
|
|
||||||
def _apply_legacy_search_filter(self, entries: List[HostEntry]) -> List[HostEntry]:
|
def _apply_legacy_search_filter(self, entries: List[HostEntry]) -> List[HostEntry]:
|
||||||
"""Apply legacy search filter for backward compatibility."""
|
"""Apply legacy search filter for backward compatibility."""
|
||||||
if not hasattr(self.app, 'search_term') or not self.app.search_term:
|
if not hasattr(self.app, "search_term") or not self.app.search_term:
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
search_term_lower = self.app.search_term.lower()
|
search_term_lower = self.app.search_term.lower()
|
||||||
|
|
@ -251,10 +255,10 @@ class TableHandler:
|
||||||
|
|
||||||
# Start with the DNS name
|
# Start with the DNS name
|
||||||
dns_display = entry.dns_name
|
dns_display = entry.dns_name
|
||||||
|
|
||||||
# Add status indicator based on resolution status
|
# Add status indicator based on resolution status
|
||||||
dns_status = entry.dns_resolution_status or "not_resolved"
|
dns_status = entry.dns_resolution_status or "not_resolved"
|
||||||
|
|
||||||
if dns_status == "not_resolved":
|
if dns_status == "not_resolved":
|
||||||
status_icon = "⏳"
|
status_icon = "⏳"
|
||||||
style = "dim yellow"
|
style = "dim yellow"
|
||||||
|
|
@ -276,7 +280,7 @@ class TableHandler:
|
||||||
else:
|
else:
|
||||||
status_icon = ""
|
status_icon = ""
|
||||||
style = "dim white"
|
style = "dim white"
|
||||||
|
|
||||||
return Text(f"{status_icon} {dns_display}", style=style)
|
return Text(f"{status_icon} {dns_display}", style=style)
|
||||||
|
|
||||||
def sort_entries_by_hostname(self) -> None:
|
def sort_entries_by_hostname(self) -> None:
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ class TestAddEntryModalDNSSupport:
|
||||||
# Test that the compose method exists and can be called
|
# Test that the compose method exists and can be called
|
||||||
# We can't test the actual widget creation without mounting the modal
|
# We can't test the actual widget creation without mounting the modal
|
||||||
# in a Textual app context, so we just verify the method exists
|
# in a Textual app context, so we just verify the method exists
|
||||||
assert hasattr(self.modal, 'compose')
|
assert hasattr(self.modal, "compose")
|
||||||
assert callable(self.modal.compose)
|
assert callable(self.modal.compose)
|
||||||
|
|
||||||
def test_validate_input_ip_entry_valid(self):
|
def test_validate_input_ip_entry_valid(self):
|
||||||
|
|
@ -39,7 +39,7 @@ class TestAddEntryModalDNSSupport:
|
||||||
ip_address="192.168.1.1",
|
ip_address="192.168.1.1",
|
||||||
dns_name="",
|
dns_name="",
|
||||||
hostnames_str="example.com",
|
hostnames_str="example.com",
|
||||||
is_dns_entry=False
|
is_dns_entry=False,
|
||||||
)
|
)
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
|
|
@ -47,12 +47,9 @@ class TestAddEntryModalDNSSupport:
|
||||||
"""Test validation for IP entry with missing IP address."""
|
"""Test validation for IP entry with missing IP address."""
|
||||||
# Mock the error display method
|
# Mock the error display method
|
||||||
self.modal._show_error = Mock()
|
self.modal._show_error = Mock()
|
||||||
|
|
||||||
result = self.modal._validate_input(
|
result = self.modal._validate_input(
|
||||||
ip_address="",
|
ip_address="", dns_name="", hostnames_str="example.com", is_dns_entry=False
|
||||||
dns_name="",
|
|
||||||
hostnames_str="example.com",
|
|
||||||
is_dns_entry=False
|
|
||||||
)
|
)
|
||||||
assert result is False
|
assert result is False
|
||||||
self.modal._show_error.assert_called_with("ip-error", "IP address is required")
|
self.modal._show_error.assert_called_with("ip-error", "IP address is required")
|
||||||
|
|
@ -63,7 +60,7 @@ class TestAddEntryModalDNSSupport:
|
||||||
ip_address="",
|
ip_address="",
|
||||||
dns_name="example.com",
|
dns_name="example.com",
|
||||||
hostnames_str="www.example.com",
|
hostnames_str="www.example.com",
|
||||||
is_dns_entry=True
|
is_dns_entry=True,
|
||||||
)
|
)
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
|
|
@ -71,12 +68,9 @@ class TestAddEntryModalDNSSupport:
|
||||||
"""Test validation for DNS entry with missing DNS name."""
|
"""Test validation for DNS entry with missing DNS name."""
|
||||||
# Mock the error display method
|
# Mock the error display method
|
||||||
self.modal._show_error = Mock()
|
self.modal._show_error = Mock()
|
||||||
|
|
||||||
result = self.modal._validate_input(
|
result = self.modal._validate_input(
|
||||||
ip_address="",
|
ip_address="", dns_name="", hostnames_str="example.com", is_dns_entry=True
|
||||||
dns_name="",
|
|
||||||
hostnames_str="example.com",
|
|
||||||
is_dns_entry=True
|
|
||||||
)
|
)
|
||||||
assert result is False
|
assert result is False
|
||||||
self.modal._show_error.assert_called_with("dns-error", "DNS name is required")
|
self.modal._show_error.assert_called_with("dns-error", "DNS name is required")
|
||||||
|
|
@ -85,55 +79,58 @@ class TestAddEntryModalDNSSupport:
|
||||||
"""Test validation for DNS entry with invalid DNS name format."""
|
"""Test validation for DNS entry with invalid DNS name format."""
|
||||||
# Mock the error display method
|
# Mock the error display method
|
||||||
self.modal._show_error = Mock()
|
self.modal._show_error = Mock()
|
||||||
|
|
||||||
# Test various invalid DNS name formats
|
# Test various invalid DNS name formats
|
||||||
invalid_dns_names = [
|
invalid_dns_names = [
|
||||||
"example .com", # Contains space
|
"example .com", # Contains space
|
||||||
".example.com", # Starts with dot
|
".example.com", # Starts with dot
|
||||||
"example.com.", # Ends with dot
|
"example.com.", # Ends with dot
|
||||||
"example..com", # Double dots
|
"example..com", # Double dots
|
||||||
"ex@mple.com", # Invalid characters
|
"ex@mple.com", # Invalid characters
|
||||||
]
|
]
|
||||||
|
|
||||||
for invalid_dns in invalid_dns_names:
|
for invalid_dns in invalid_dns_names:
|
||||||
result = self.modal._validate_input(
|
result = self.modal._validate_input(
|
||||||
ip_address="",
|
ip_address="",
|
||||||
dns_name=invalid_dns,
|
dns_name=invalid_dns,
|
||||||
hostnames_str="example.com",
|
hostnames_str="example.com",
|
||||||
is_dns_entry=True
|
is_dns_entry=True,
|
||||||
)
|
)
|
||||||
assert result is False
|
assert result is False
|
||||||
self.modal._show_error.assert_called_with("dns-error", "Invalid DNS name format")
|
self.modal._show_error.assert_called_with(
|
||||||
|
"dns-error", "Invalid DNS name format"
|
||||||
|
)
|
||||||
|
|
||||||
def test_validate_input_missing_hostnames(self):
|
def test_validate_input_missing_hostnames(self):
|
||||||
"""Test validation for entries with missing hostnames."""
|
"""Test validation for entries with missing hostnames."""
|
||||||
# Mock the error display method
|
# Mock the error display method
|
||||||
self.modal._show_error = Mock()
|
self.modal._show_error = Mock()
|
||||||
|
|
||||||
# Test IP entry without hostnames
|
# Test IP entry without hostnames
|
||||||
result = self.modal._validate_input(
|
result = self.modal._validate_input(
|
||||||
ip_address="192.168.1.1",
|
ip_address="192.168.1.1", dns_name="", hostnames_str="", is_dns_entry=False
|
||||||
dns_name="",
|
|
||||||
hostnames_str="",
|
|
||||||
is_dns_entry=False
|
|
||||||
)
|
)
|
||||||
assert result is False
|
assert result is False
|
||||||
self.modal._show_error.assert_called_with("hostnames-error", "At least one hostname is required")
|
self.modal._show_error.assert_called_with(
|
||||||
|
"hostnames-error", "At least one hostname is required"
|
||||||
|
)
|
||||||
|
|
||||||
def test_validate_input_invalid_hostnames(self):
|
def test_validate_input_invalid_hostnames(self):
|
||||||
"""Test validation for entries with invalid hostnames."""
|
"""Test validation for entries with invalid hostnames."""
|
||||||
# Mock the error display method
|
# Mock the error display method
|
||||||
self.modal._show_error = Mock()
|
self.modal._show_error = Mock()
|
||||||
|
|
||||||
# Test with invalid hostname containing spaces
|
# Test with invalid hostname containing spaces
|
||||||
result = self.modal._validate_input(
|
result = self.modal._validate_input(
|
||||||
ip_address="192.168.1.1",
|
ip_address="192.168.1.1",
|
||||||
dns_name="",
|
dns_name="",
|
||||||
hostnames_str="invalid hostname",
|
hostnames_str="invalid hostname",
|
||||||
is_dns_entry=False
|
is_dns_entry=False,
|
||||||
)
|
)
|
||||||
assert result is False
|
assert result is False
|
||||||
self.modal._show_error.assert_called_with("hostnames-error", "Invalid hostname format: invalid hostname")
|
self.modal._show_error.assert_called_with(
|
||||||
|
"hostnames-error", "Invalid hostname format: invalid hostname"
|
||||||
|
)
|
||||||
|
|
||||||
def test_clear_errors_includes_dns_error(self):
|
def test_clear_errors_includes_dns_error(self):
|
||||||
"""Test that clear_errors method includes DNS error clearing."""
|
"""Test that clear_errors method includes DNS error clearing."""
|
||||||
|
|
@ -141,7 +138,7 @@ class TestAddEntryModalDNSSupport:
|
||||||
mock_ip_error = Mock(spec=Static)
|
mock_ip_error = Mock(spec=Static)
|
||||||
mock_dns_error = Mock(spec=Static)
|
mock_dns_error = Mock(spec=Static)
|
||||||
mock_hostnames_error = Mock(spec=Static)
|
mock_hostnames_error = Mock(spec=Static)
|
||||||
|
|
||||||
def mock_query_one(selector, widget_type):
|
def mock_query_one(selector, widget_type):
|
||||||
if selector == "#ip-error":
|
if selector == "#ip-error":
|
||||||
return mock_ip_error
|
return mock_ip_error
|
||||||
|
|
@ -150,12 +147,12 @@ class TestAddEntryModalDNSSupport:
|
||||||
elif selector == "#hostnames-error":
|
elif selector == "#hostnames-error":
|
||||||
return mock_hostnames_error
|
return mock_hostnames_error
|
||||||
return Mock()
|
return Mock()
|
||||||
|
|
||||||
self.modal.query_one = Mock(side_effect=mock_query_one)
|
self.modal.query_one = Mock(side_effect=mock_query_one)
|
||||||
|
|
||||||
# Call clear_errors
|
# Call clear_errors
|
||||||
self.modal._clear_errors()
|
self.modal._clear_errors()
|
||||||
|
|
||||||
# Verify all error widgets were cleared
|
# Verify all error widgets were cleared
|
||||||
mock_ip_error.update.assert_called_with("")
|
mock_ip_error.update.assert_called_with("")
|
||||||
mock_dns_error.update.assert_called_with("")
|
mock_dns_error.update.assert_called_with("")
|
||||||
|
|
@ -166,10 +163,10 @@ class TestAddEntryModalDNSSupport:
|
||||||
# Mock the query_one method to return a mock widget
|
# Mock the query_one method to return a mock widget
|
||||||
mock_error_widget = Mock(spec=Static)
|
mock_error_widget = Mock(spec=Static)
|
||||||
self.modal.query_one = Mock(return_value=mock_error_widget)
|
self.modal.query_one = Mock(return_value=mock_error_widget)
|
||||||
|
|
||||||
# Test showing an error
|
# Test showing an error
|
||||||
self.modal._show_error("dns-error", "Test error message")
|
self.modal._show_error("dns-error", "Test error message")
|
||||||
|
|
||||||
# Verify the error widget was updated
|
# Verify the error widget was updated
|
||||||
self.modal.query_one.assert_called_with("#dns-error", Static)
|
self.modal.query_one.assert_called_with("#dns-error", Static)
|
||||||
mock_error_widget.update.assert_called_with("Test error message")
|
mock_error_widget.update.assert_called_with("Test error message")
|
||||||
|
|
@ -178,7 +175,7 @@ class TestAddEntryModalDNSSupport:
|
||||||
"""Test that show_error handles missing widgets gracefully."""
|
"""Test that show_error handles missing widgets gracefully."""
|
||||||
# Mock query_one to raise an exception
|
# Mock query_one to raise an exception
|
||||||
self.modal.query_one = Mock(side_effect=Exception("Widget not found"))
|
self.modal.query_one = Mock(side_effect=Exception("Widget not found"))
|
||||||
|
|
||||||
# This should not raise an exception
|
# This should not raise an exception
|
||||||
try:
|
try:
|
||||||
self.modal._show_error("dns-error", "Test error message")
|
self.modal._show_error("dns-error", "Test error message")
|
||||||
|
|
@ -199,7 +196,7 @@ class TestAddEntryModalRadioButtonLogic:
|
||||||
mock_ip_section = Mock()
|
mock_ip_section = Mock()
|
||||||
mock_dns_section = Mock()
|
mock_dns_section = Mock()
|
||||||
mock_ip_input = Mock(spec=Input)
|
mock_ip_input = Mock(spec=Input)
|
||||||
|
|
||||||
def mock_query_one(selector, widget_type=None):
|
def mock_query_one(selector, widget_type=None):
|
||||||
if selector == "#ip-section":
|
if selector == "#ip-section":
|
||||||
return mock_ip_section
|
return mock_ip_section
|
||||||
|
|
@ -208,25 +205,25 @@ class TestAddEntryModalRadioButtonLogic:
|
||||||
elif selector == "#ip-address-input":
|
elif selector == "#ip-address-input":
|
||||||
return mock_ip_input
|
return mock_ip_input
|
||||||
return Mock()
|
return Mock()
|
||||||
|
|
||||||
self.modal.query_one = Mock(side_effect=mock_query_one)
|
self.modal.query_one = Mock(side_effect=mock_query_one)
|
||||||
|
|
||||||
# Create mock event
|
# Create mock event
|
||||||
mock_radio = Mock()
|
mock_radio = Mock()
|
||||||
mock_radio.id = "ip-entry-radio"
|
mock_radio.id = "ip-entry-radio"
|
||||||
mock_radio_set = Mock()
|
mock_radio_set = Mock()
|
||||||
mock_radio_set.id = "entry-type-radio"
|
mock_radio_set.id = "entry-type-radio"
|
||||||
|
|
||||||
class MockEvent:
|
class MockEvent:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.radio_set = mock_radio_set
|
self.radio_set = mock_radio_set
|
||||||
self.pressed = mock_radio
|
self.pressed = mock_radio
|
||||||
|
|
||||||
event = MockEvent()
|
event = MockEvent()
|
||||||
|
|
||||||
# Call the event handler
|
# Call the event handler
|
||||||
self.modal.on_radio_set_changed(event)
|
self.modal.on_radio_set_changed(event)
|
||||||
|
|
||||||
# Verify IP section is shown and DNS section is hidden
|
# Verify IP section is shown and DNS section is hidden
|
||||||
mock_ip_section.remove_class.assert_called_with("hidden")
|
mock_ip_section.remove_class.assert_called_with("hidden")
|
||||||
mock_dns_section.add_class.assert_called_with("hidden")
|
mock_dns_section.add_class.assert_called_with("hidden")
|
||||||
|
|
@ -238,7 +235,7 @@ class TestAddEntryModalRadioButtonLogic:
|
||||||
mock_ip_section = Mock()
|
mock_ip_section = Mock()
|
||||||
mock_dns_section = Mock()
|
mock_dns_section = Mock()
|
||||||
mock_dns_input = Mock(spec=Input)
|
mock_dns_input = Mock(spec=Input)
|
||||||
|
|
||||||
def mock_query_one(selector, widget_type=None):
|
def mock_query_one(selector, widget_type=None):
|
||||||
if selector == "#ip-section":
|
if selector == "#ip-section":
|
||||||
return mock_ip_section
|
return mock_ip_section
|
||||||
|
|
@ -247,25 +244,25 @@ class TestAddEntryModalRadioButtonLogic:
|
||||||
elif selector == "#dns-name-input":
|
elif selector == "#dns-name-input":
|
||||||
return mock_dns_input
|
return mock_dns_input
|
||||||
return Mock()
|
return Mock()
|
||||||
|
|
||||||
self.modal.query_one = Mock(side_effect=mock_query_one)
|
self.modal.query_one = Mock(side_effect=mock_query_one)
|
||||||
|
|
||||||
# Create mock event
|
# Create mock event
|
||||||
mock_radio = Mock()
|
mock_radio = Mock()
|
||||||
mock_radio.id = "dns-entry-radio"
|
mock_radio.id = "dns-entry-radio"
|
||||||
mock_radio_set = Mock()
|
mock_radio_set = Mock()
|
||||||
mock_radio_set.id = "entry-type-radio"
|
mock_radio_set.id = "entry-type-radio"
|
||||||
|
|
||||||
class MockEvent:
|
class MockEvent:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.radio_set = mock_radio_set
|
self.radio_set = mock_radio_set
|
||||||
self.pressed = mock_radio
|
self.pressed = mock_radio
|
||||||
|
|
||||||
event = MockEvent()
|
event = MockEvent()
|
||||||
|
|
||||||
# Call the event handler
|
# Call the event handler
|
||||||
self.modal.on_radio_set_changed(event)
|
self.modal.on_radio_set_changed(event)
|
||||||
|
|
||||||
# Verify DNS section is shown and IP section is hidden
|
# Verify DNS section is shown and IP section is hidden
|
||||||
mock_ip_section.add_class.assert_called_with("hidden")
|
mock_ip_section.add_class.assert_called_with("hidden")
|
||||||
mock_dns_section.remove_class.assert_called_with("hidden")
|
mock_dns_section.remove_class.assert_called_with("hidden")
|
||||||
|
|
@ -285,26 +282,26 @@ class TestAddEntryModalSaveLogic:
|
||||||
self.modal._validate_input = Mock(return_value=True)
|
self.modal._validate_input = Mock(return_value=True)
|
||||||
self.modal._clear_errors = Mock()
|
self.modal._clear_errors = Mock()
|
||||||
self.modal.dismiss = Mock()
|
self.modal.dismiss = Mock()
|
||||||
|
|
||||||
# Mock form widgets
|
# Mock form widgets
|
||||||
mock_radio_set = Mock(spec=RadioSet)
|
mock_radio_set = Mock(spec=RadioSet)
|
||||||
mock_radio_set.pressed_button = None # IP entry mode
|
mock_radio_set.pressed_button = None # IP entry mode
|
||||||
|
|
||||||
mock_ip_input = Mock(spec=Input)
|
mock_ip_input = Mock(spec=Input)
|
||||||
mock_ip_input.value = "192.168.1.1"
|
mock_ip_input.value = "192.168.1.1"
|
||||||
|
|
||||||
mock_dns_input = Mock(spec=Input)
|
mock_dns_input = Mock(spec=Input)
|
||||||
mock_dns_input.value = ""
|
mock_dns_input.value = ""
|
||||||
|
|
||||||
mock_hostnames_input = Mock(spec=Input)
|
mock_hostnames_input = Mock(spec=Input)
|
||||||
mock_hostnames_input.value = "example.com, www.example.com"
|
mock_hostnames_input.value = "example.com, www.example.com"
|
||||||
|
|
||||||
mock_comment_input = Mock(spec=Input)
|
mock_comment_input = Mock(spec=Input)
|
||||||
mock_comment_input.value = "Test comment"
|
mock_comment_input.value = "Test comment"
|
||||||
|
|
||||||
mock_active_checkbox = Mock(spec=Checkbox)
|
mock_active_checkbox = Mock(spec=Checkbox)
|
||||||
mock_active_checkbox.value = True
|
mock_active_checkbox.value = True
|
||||||
|
|
||||||
def mock_query_one(selector, widget_type):
|
def mock_query_one(selector, widget_type):
|
||||||
if selector == "#entry-type-radio":
|
if selector == "#entry-type-radio":
|
||||||
return mock_radio_set
|
return mock_radio_set
|
||||||
|
|
@ -319,17 +316,17 @@ class TestAddEntryModalSaveLogic:
|
||||||
elif selector == "#active-checkbox":
|
elif selector == "#active-checkbox":
|
||||||
return mock_active_checkbox
|
return mock_active_checkbox
|
||||||
return Mock()
|
return Mock()
|
||||||
|
|
||||||
self.modal.query_one = Mock(side_effect=mock_query_one)
|
self.modal.query_one = Mock(side_effect=mock_query_one)
|
||||||
|
|
||||||
# Call action_save
|
# Call action_save
|
||||||
self.modal.action_save()
|
self.modal.action_save()
|
||||||
|
|
||||||
# Verify validation was called
|
# Verify validation was called
|
||||||
self.modal._validate_input.assert_called_once_with(
|
self.modal._validate_input.assert_called_once_with(
|
||||||
"192.168.1.1", "", "example.com, www.example.com", None
|
"192.168.1.1", "", "example.com, www.example.com", None
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify modal was dismissed with a HostEntry
|
# Verify modal was dismissed with a HostEntry
|
||||||
self.modal.dismiss.assert_called_once()
|
self.modal.dismiss.assert_called_once()
|
||||||
created_entry = self.modal.dismiss.call_args[0][0]
|
created_entry = self.modal.dismiss.call_args[0][0]
|
||||||
|
|
@ -345,28 +342,28 @@ class TestAddEntryModalSaveLogic:
|
||||||
self.modal._validate_input = Mock(return_value=True)
|
self.modal._validate_input = Mock(return_value=True)
|
||||||
self.modal._clear_errors = Mock()
|
self.modal._clear_errors = Mock()
|
||||||
self.modal.dismiss = Mock()
|
self.modal.dismiss = Mock()
|
||||||
|
|
||||||
# Mock form widgets
|
# Mock form widgets
|
||||||
mock_radio_button = Mock()
|
mock_radio_button = Mock()
|
||||||
mock_radio_button.id = "dns-entry-radio"
|
mock_radio_button.id = "dns-entry-radio"
|
||||||
mock_radio_set = Mock(spec=RadioSet)
|
mock_radio_set = Mock(spec=RadioSet)
|
||||||
mock_radio_set.pressed_button = mock_radio_button
|
mock_radio_set.pressed_button = mock_radio_button
|
||||||
|
|
||||||
mock_ip_input = Mock(spec=Input)
|
mock_ip_input = Mock(spec=Input)
|
||||||
mock_ip_input.value = ""
|
mock_ip_input.value = ""
|
||||||
|
|
||||||
mock_dns_input = Mock(spec=Input)
|
mock_dns_input = Mock(spec=Input)
|
||||||
mock_dns_input.value = "example.com"
|
mock_dns_input.value = "example.com"
|
||||||
|
|
||||||
mock_hostnames_input = Mock(spec=Input)
|
mock_hostnames_input = Mock(spec=Input)
|
||||||
mock_hostnames_input.value = "www.example.com"
|
mock_hostnames_input.value = "www.example.com"
|
||||||
|
|
||||||
mock_comment_input = Mock(spec=Input)
|
mock_comment_input = Mock(spec=Input)
|
||||||
mock_comment_input.value = ""
|
mock_comment_input.value = ""
|
||||||
|
|
||||||
mock_active_checkbox = Mock(spec=Checkbox)
|
mock_active_checkbox = Mock(spec=Checkbox)
|
||||||
mock_active_checkbox.value = True
|
mock_active_checkbox.value = True
|
||||||
|
|
||||||
def mock_query_one(selector, widget_type):
|
def mock_query_one(selector, widget_type):
|
||||||
if selector == "#entry-type-radio":
|
if selector == "#entry-type-radio":
|
||||||
return mock_radio_set
|
return mock_radio_set
|
||||||
|
|
@ -381,23 +378,23 @@ class TestAddEntryModalSaveLogic:
|
||||||
elif selector == "#active-checkbox":
|
elif selector == "#active-checkbox":
|
||||||
return mock_active_checkbox
|
return mock_active_checkbox
|
||||||
return Mock()
|
return Mock()
|
||||||
|
|
||||||
self.modal.query_one = Mock(side_effect=mock_query_one)
|
self.modal.query_one = Mock(side_effect=mock_query_one)
|
||||||
|
|
||||||
# Call action_save
|
# Call action_save
|
||||||
self.modal.action_save()
|
self.modal.action_save()
|
||||||
|
|
||||||
# Verify validation was called
|
# Verify validation was called
|
||||||
self.modal._validate_input.assert_called_once_with(
|
self.modal._validate_input.assert_called_once_with(
|
||||||
"", "example.com", "www.example.com", True
|
"", "example.com", "www.example.com", True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify modal was dismissed with a DNS HostEntry
|
# Verify modal was dismissed with a DNS HostEntry
|
||||||
self.modal.dismiss.assert_called_once()
|
self.modal.dismiss.assert_called_once()
|
||||||
created_entry = self.modal.dismiss.call_args[0][0]
|
created_entry = self.modal.dismiss.call_args[0][0]
|
||||||
assert isinstance(created_entry, HostEntry)
|
assert isinstance(created_entry, HostEntry)
|
||||||
assert created_entry.ip_address == "0.0.0.0" # Placeholder IP for DNS entries
|
assert created_entry.ip_address == "0.0.0.0" # Placeholder IP for DNS entries
|
||||||
assert hasattr(created_entry, 'dns_name')
|
assert hasattr(created_entry, "dns_name")
|
||||||
assert created_entry.dns_name == "example.com"
|
assert created_entry.dns_name == "example.com"
|
||||||
assert created_entry.hostnames == ["www.example.com"]
|
assert created_entry.hostnames == ["www.example.com"]
|
||||||
assert created_entry.comment is None
|
assert created_entry.comment is None
|
||||||
|
|
@ -409,21 +406,21 @@ class TestAddEntryModalSaveLogic:
|
||||||
self.modal._validate_input = Mock(return_value=False)
|
self.modal._validate_input = Mock(return_value=False)
|
||||||
self.modal._clear_errors = Mock()
|
self.modal._clear_errors = Mock()
|
||||||
self.modal.dismiss = Mock()
|
self.modal.dismiss = Mock()
|
||||||
|
|
||||||
# Mock form widgets (minimal setup since validation fails)
|
# Mock form widgets (minimal setup since validation fails)
|
||||||
mock_radio_set = Mock(spec=RadioSet)
|
mock_radio_set = Mock(spec=RadioSet)
|
||||||
mock_radio_set.pressed_button = None
|
mock_radio_set.pressed_button = None
|
||||||
|
|
||||||
def mock_query_one(selector, widget_type):
|
def mock_query_one(selector, widget_type):
|
||||||
if selector == "#entry-type-radio":
|
if selector == "#entry-type-radio":
|
||||||
return mock_radio_set
|
return mock_radio_set
|
||||||
return Mock(spec=Input, value="")
|
return Mock(spec=Input, value="")
|
||||||
|
|
||||||
self.modal.query_one = Mock(side_effect=mock_query_one)
|
self.modal.query_one = Mock(side_effect=mock_query_one)
|
||||||
|
|
||||||
# Call action_save
|
# Call action_save
|
||||||
self.modal.action_save()
|
self.modal.action_save()
|
||||||
|
|
||||||
# Verify validation was called and modal was not dismissed
|
# Verify validation was called and modal was not dismissed
|
||||||
self.modal._validate_input.assert_called_once()
|
self.modal._validate_input.assert_called_once()
|
||||||
self.modal.dismiss.assert_not_called()
|
self.modal.dismiss.assert_not_called()
|
||||||
|
|
@ -434,30 +431,33 @@ class TestAddEntryModalSaveLogic:
|
||||||
self.modal._validate_input = Mock(return_value=True)
|
self.modal._validate_input = Mock(return_value=True)
|
||||||
self.modal._clear_errors = Mock()
|
self.modal._clear_errors = Mock()
|
||||||
self.modal._show_error = Mock()
|
self.modal._show_error = Mock()
|
||||||
|
|
||||||
# Mock form widgets
|
# Mock form widgets
|
||||||
mock_radio_set = Mock(spec=RadioSet)
|
mock_radio_set = Mock(spec=RadioSet)
|
||||||
mock_radio_set.pressed_button = None
|
mock_radio_set.pressed_button = None
|
||||||
|
|
||||||
mock_input = Mock(spec=Input)
|
mock_input = Mock(spec=Input)
|
||||||
mock_input.value = "invalid"
|
mock_input.value = "invalid"
|
||||||
|
|
||||||
def mock_query_one(selector, widget_type):
|
def mock_query_one(selector, widget_type):
|
||||||
if selector == "#entry-type-radio":
|
if selector == "#entry-type-radio":
|
||||||
return mock_radio_set
|
return mock_radio_set
|
||||||
return mock_input
|
return mock_input
|
||||||
|
|
||||||
self.modal.query_one = Mock(side_effect=mock_query_one)
|
self.modal.query_one = Mock(side_effect=mock_query_one)
|
||||||
|
|
||||||
# Mock HostEntry to raise ValueError
|
# Mock HostEntry to raise ValueError
|
||||||
with pytest.MonkeyPatch.context() as m:
|
with pytest.MonkeyPatch.context() as m:
|
||||||
|
|
||||||
def mock_host_entry(*args, **kwargs):
|
def mock_host_entry(*args, **kwargs):
|
||||||
raise ValueError("Invalid IP address")
|
raise ValueError("Invalid IP address")
|
||||||
|
|
||||||
m.setattr("src.hosts.tui.add_entry_modal.HostEntry", mock_host_entry)
|
m.setattr("src.hosts.tui.add_entry_modal.HostEntry", mock_host_entry)
|
||||||
|
|
||||||
# Call action_save
|
# Call action_save
|
||||||
self.modal.action_save()
|
self.modal.action_save()
|
||||||
|
|
||||||
# Verify error was shown
|
# Verify error was shown
|
||||||
self.modal._show_error.assert_called_once_with("hostnames-error", "Invalid IP address")
|
self.modal._show_error.assert_called_once_with(
|
||||||
|
"hostnames-error", "Invalid IP address"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -179,13 +179,13 @@ class TestUndoRedoHistory:
|
||||||
"""Test that executing a new command clears the redo stack."""
|
"""Test that executing a new command clears the redo stack."""
|
||||||
history = UndoRedoHistory()
|
history = UndoRedoHistory()
|
||||||
hosts_file = HostsFile()
|
hosts_file = HostsFile()
|
||||||
|
|
||||||
# Execute and undo a command
|
# Execute and undo a command
|
||||||
command1 = Mock(spec=Command)
|
command1 = Mock(spec=Command)
|
||||||
command1.execute.return_value = OperationResult(True, "Command 1")
|
command1.execute.return_value = OperationResult(True, "Command 1")
|
||||||
command1.undo.return_value = OperationResult(True, "Undo 1")
|
command1.undo.return_value = OperationResult(True, "Undo 1")
|
||||||
command1.get_description.return_value = "Command 1"
|
command1.get_description.return_value = "Command 1"
|
||||||
|
|
||||||
history.execute_command(command1, hosts_file)
|
history.execute_command(command1, hosts_file)
|
||||||
history.undo(hosts_file)
|
history.undo(hosts_file)
|
||||||
assert history.can_redo()
|
assert history.can_redo()
|
||||||
|
|
@ -194,7 +194,7 @@ class TestUndoRedoHistory:
|
||||||
command2 = Mock(spec=Command)
|
command2 = Mock(spec=Command)
|
||||||
command2.execute.return_value = OperationResult(True, "Command 2")
|
command2.execute.return_value = OperationResult(True, "Command 2")
|
||||||
command2.get_description.return_value = "Command 2"
|
command2.get_description.return_value = "Command 2"
|
||||||
|
|
||||||
history.execute_command(command2, hosts_file)
|
history.execute_command(command2, hosts_file)
|
||||||
assert not history.can_redo() # Redo stack should be cleared
|
assert not history.can_redo() # Redo stack should be cleared
|
||||||
|
|
||||||
|
|
@ -205,7 +205,9 @@ class TestToggleEntryCommand:
|
||||||
def test_toggle_active_to_inactive(self):
|
def test_toggle_active_to_inactive(self):
|
||||||
"""Test toggling an active entry to inactive."""
|
"""Test toggling an active entry to inactive."""
|
||||||
hosts_file = HostsFile()
|
hosts_file = HostsFile()
|
||||||
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"], is_active=True)
|
entry = HostEntry(
|
||||||
|
ip_address="192.168.1.1", hostnames=["test.local"], is_active=True
|
||||||
|
)
|
||||||
hosts_file.entries.append(entry)
|
hosts_file.entries.append(entry)
|
||||||
|
|
||||||
command = ToggleEntryCommand(0)
|
command = ToggleEntryCommand(0)
|
||||||
|
|
@ -218,7 +220,9 @@ class TestToggleEntryCommand:
|
||||||
def test_toggle_inactive_to_active(self):
|
def test_toggle_inactive_to_active(self):
|
||||||
"""Test toggling an inactive entry to active."""
|
"""Test toggling an inactive entry to active."""
|
||||||
hosts_file = HostsFile()
|
hosts_file = HostsFile()
|
||||||
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"], is_active=False)
|
entry = HostEntry(
|
||||||
|
ip_address="192.168.1.1", hostnames=["test.local"], is_active=False
|
||||||
|
)
|
||||||
hosts_file.entries.append(entry)
|
hosts_file.entries.append(entry)
|
||||||
|
|
||||||
command = ToggleEntryCommand(0)
|
command = ToggleEntryCommand(0)
|
||||||
|
|
@ -231,7 +235,9 @@ class TestToggleEntryCommand:
|
||||||
def test_toggle_undo(self):
|
def test_toggle_undo(self):
|
||||||
"""Test undoing a toggle operation."""
|
"""Test undoing a toggle operation."""
|
||||||
hosts_file = HostsFile()
|
hosts_file = HostsFile()
|
||||||
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"], is_active=True)
|
entry = HostEntry(
|
||||||
|
ip_address="192.168.1.1", hostnames=["test.local"], is_active=True
|
||||||
|
)
|
||||||
hosts_file.entries.append(entry)
|
hosts_file.entries.append(entry)
|
||||||
|
|
||||||
command = ToggleEntryCommand(0)
|
command = ToggleEntryCommand(0)
|
||||||
|
|
@ -454,10 +460,17 @@ class TestUpdateEntryCommand:
|
||||||
def test_update_entry(self):
|
def test_update_entry(self):
|
||||||
"""Test updating an entry."""
|
"""Test updating an entry."""
|
||||||
hosts_file = HostsFile()
|
hosts_file = HostsFile()
|
||||||
old_entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"], comment="old comment", is_active=True)
|
old_entry = HostEntry(
|
||||||
|
ip_address="192.168.1.1",
|
||||||
|
hostnames=["test.local"],
|
||||||
|
comment="old comment",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
hosts_file.entries.append(old_entry)
|
hosts_file.entries.append(old_entry)
|
||||||
|
|
||||||
command = UpdateEntryCommand(0, "192.168.1.2", ["updated.local"], "new comment", False)
|
command = UpdateEntryCommand(
|
||||||
|
0, "192.168.1.2", ["updated.local"], "new comment", False
|
||||||
|
)
|
||||||
result = command.execute(hosts_file)
|
result = command.execute(hosts_file)
|
||||||
|
|
||||||
assert result.success is True
|
assert result.success is True
|
||||||
|
|
@ -470,10 +483,17 @@ class TestUpdateEntryCommand:
|
||||||
def test_update_entry_undo(self):
|
def test_update_entry_undo(self):
|
||||||
"""Test undoing an update operation."""
|
"""Test undoing an update operation."""
|
||||||
hosts_file = HostsFile()
|
hosts_file = HostsFile()
|
||||||
old_entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"], comment="old comment", is_active=True)
|
old_entry = HostEntry(
|
||||||
|
ip_address="192.168.1.1",
|
||||||
|
hostnames=["test.local"],
|
||||||
|
comment="old comment",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
hosts_file.entries.append(old_entry)
|
hosts_file.entries.append(old_entry)
|
||||||
|
|
||||||
command = UpdateEntryCommand(0, "192.168.1.2", ["updated.local"], "new comment", False)
|
command = UpdateEntryCommand(
|
||||||
|
0, "192.168.1.2", ["updated.local"], "new comment", False
|
||||||
|
)
|
||||||
command.execute(hosts_file)
|
command.execute(hosts_file)
|
||||||
result = command.undo(hosts_file)
|
result = command.undo(hosts_file)
|
||||||
|
|
||||||
|
|
@ -507,7 +527,7 @@ class TestHostsManagerIntegration:
|
||||||
def test_manager_undo_redo_properties(self):
|
def test_manager_undo_redo_properties(self):
|
||||||
"""Test undo/redo availability properties."""
|
"""Test undo/redo availability properties."""
|
||||||
manager = HostsManager()
|
manager = HostsManager()
|
||||||
|
|
||||||
# Initially no undo/redo available
|
# Initially no undo/redo available
|
||||||
assert not manager.can_undo()
|
assert not manager.can_undo()
|
||||||
assert not manager.can_redo()
|
assert not manager.can_redo()
|
||||||
|
|
@ -519,7 +539,7 @@ class TestHostsManagerIntegration:
|
||||||
manager = HostsManager()
|
manager = HostsManager()
|
||||||
hosts_file = HostsFile()
|
hosts_file = HostsFile()
|
||||||
result = manager.undo_last_operation(hosts_file)
|
result = manager.undo_last_operation(hosts_file)
|
||||||
|
|
||||||
assert result.success is False
|
assert result.success is False
|
||||||
assert "Not in edit mode" in result.message
|
assert "Not in edit mode" in result.message
|
||||||
|
|
||||||
|
|
@ -528,46 +548,48 @@ class TestHostsManagerIntegration:
|
||||||
manager = HostsManager()
|
manager = HostsManager()
|
||||||
hosts_file = HostsFile()
|
hosts_file = HostsFile()
|
||||||
result = manager.redo_last_operation(hosts_file)
|
result = manager.redo_last_operation(hosts_file)
|
||||||
|
|
||||||
assert result.success is False
|
assert result.success is False
|
||||||
assert "Not in edit mode" in result.message
|
assert "Not in edit mode" in result.message
|
||||||
|
|
||||||
@patch('src.hosts.core.manager.HostsManager.enter_edit_mode')
|
@patch("src.hosts.core.manager.HostsManager.enter_edit_mode")
|
||||||
def test_manager_execute_toggle_command(self, mock_enter_edit):
|
def test_manager_execute_toggle_command(self, mock_enter_edit):
|
||||||
"""Test executing a toggle command through the manager."""
|
"""Test executing a toggle command through the manager."""
|
||||||
mock_enter_edit.return_value = (True, "Edit mode enabled")
|
mock_enter_edit.return_value = (True, "Edit mode enabled")
|
||||||
|
|
||||||
manager = HostsManager()
|
manager = HostsManager()
|
||||||
manager.edit_mode = True # Simulate edit mode
|
manager.edit_mode = True # Simulate edit mode
|
||||||
|
|
||||||
hosts_file = HostsFile()
|
hosts_file = HostsFile()
|
||||||
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"], is_active=True)
|
entry = HostEntry(
|
||||||
|
ip_address="192.168.1.1", hostnames=["test.local"], is_active=True
|
||||||
|
)
|
||||||
hosts_file.entries.append(entry)
|
hosts_file.entries.append(entry)
|
||||||
|
|
||||||
result = manager.execute_toggle_command(hosts_file, 0)
|
result = manager.execute_toggle_command(hosts_file, 0)
|
||||||
|
|
||||||
assert result.success is True
|
assert result.success is True
|
||||||
assert not hosts_file.entries[0].is_active
|
assert not hosts_file.entries[0].is_active
|
||||||
|
|
||||||
@patch('src.hosts.core.manager.HostsManager.enter_edit_mode')
|
@patch("src.hosts.core.manager.HostsManager.enter_edit_mode")
|
||||||
def test_manager_execute_move_command(self, mock_enter_edit):
|
def test_manager_execute_move_command(self, mock_enter_edit):
|
||||||
"""Test executing a move command through the manager."""
|
"""Test executing a move command through the manager."""
|
||||||
mock_enter_edit.return_value = (True, "Edit mode enabled")
|
mock_enter_edit.return_value = (True, "Edit mode enabled")
|
||||||
|
|
||||||
manager = HostsManager()
|
manager = HostsManager()
|
||||||
manager.edit_mode = True # Simulate edit mode
|
manager.edit_mode = True # Simulate edit mode
|
||||||
|
|
||||||
hosts_file = HostsFile()
|
hosts_file = HostsFile()
|
||||||
entry1 = HostEntry(ip_address="192.168.1.1", hostnames=["test1.local"])
|
entry1 = HostEntry(ip_address="192.168.1.1", hostnames=["test1.local"])
|
||||||
entry2 = HostEntry(ip_address="192.168.1.2", hostnames=["test2.local"])
|
entry2 = HostEntry(ip_address="192.168.1.2", hostnames=["test2.local"])
|
||||||
hosts_file.entries.extend([entry1, entry2])
|
hosts_file.entries.extend([entry1, entry2])
|
||||||
|
|
||||||
result = manager.execute_move_command(hosts_file, 1, "up")
|
result = manager.execute_move_command(hosts_file, 1, "up")
|
||||||
|
|
||||||
assert result.success is True
|
assert result.success is True
|
||||||
assert hosts_file.entries[0] == entry2
|
assert hosts_file.entries[0] == entry2
|
||||||
|
|
||||||
@patch('src.hosts.core.manager.HostsManager.enter_edit_mode')
|
@patch("src.hosts.core.manager.HostsManager.enter_edit_mode")
|
||||||
def test_manager_command_not_in_edit_mode(self, mock_enter_edit):
|
def test_manager_command_not_in_edit_mode(self, mock_enter_edit):
|
||||||
"""Test executing commands when not in edit mode."""
|
"""Test executing commands when not in edit mode."""
|
||||||
manager = HostsManager()
|
manager = HostsManager()
|
||||||
|
|
@ -575,7 +597,7 @@ class TestHostsManagerIntegration:
|
||||||
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"])
|
entry = HostEntry(ip_address="192.168.1.1", hostnames=["test.local"])
|
||||||
|
|
||||||
result = manager.execute_add_command(hosts_file, entry)
|
result = manager.execute_add_command(hosts_file, entry)
|
||||||
|
|
||||||
assert result.success is False
|
assert result.success is False
|
||||||
assert "Not in edit mode" in result.message
|
assert "Not in edit mode" in result.message
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -115,9 +115,10 @@ class TestResolveHostname:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_timeout_resolution(self):
|
async def test_timeout_resolution(self):
|
||||||
"""Test hostname resolution timeout."""
|
"""Test hostname resolution timeout."""
|
||||||
|
|
||||||
async def mock_wait_for(*args, **kwargs):
|
async def mock_wait_for(*args, **kwargs):
|
||||||
raise asyncio.TimeoutError()
|
raise asyncio.TimeoutError()
|
||||||
|
|
||||||
with patch("asyncio.wait_for", side_effect=mock_wait_for) as mock_wait_for:
|
with patch("asyncio.wait_for", side_effect=mock_wait_for) as mock_wait_for:
|
||||||
resolution = await resolve_hostname("slow.example", timeout=1.0)
|
resolution = await resolve_hostname("slow.example", timeout=1.0)
|
||||||
|
|
||||||
|
|
@ -142,9 +143,10 @@ class TestResolveHostname:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_empty_result_resolution(self):
|
async def test_empty_result_resolution(self):
|
||||||
"""Test hostname resolution with empty result."""
|
"""Test hostname resolution with empty result."""
|
||||||
|
|
||||||
async def mock_wait_for(*args, **kwargs):
|
async def mock_wait_for(*args, **kwargs):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
with patch("asyncio.get_event_loop") as mock_loop:
|
with patch("asyncio.get_event_loop") as mock_loop:
|
||||||
mock_event_loop = AsyncMock()
|
mock_event_loop = AsyncMock()
|
||||||
mock_loop.return_value = mock_event_loop
|
mock_loop.return_value = mock_event_loop
|
||||||
|
|
@ -167,7 +169,9 @@ class TestResolveHostnamesBatch:
|
||||||
"""Test successful batch hostname resolution."""
|
"""Test successful batch hostname resolution."""
|
||||||
hostnames = ["example.com", "test.example"]
|
hostnames = ["example.com", "test.example"]
|
||||||
|
|
||||||
with patch("src.hosts.core.dns.resolve_hostname", new_callable=AsyncMock) as mock_resolve:
|
with patch(
|
||||||
|
"src.hosts.core.dns.resolve_hostname", new_callable=AsyncMock
|
||||||
|
) as mock_resolve:
|
||||||
# Mock successful resolutions
|
# Mock successful resolutions
|
||||||
mock_resolve.side_effect = [
|
mock_resolve.side_effect = [
|
||||||
DNSResolution(
|
DNSResolution(
|
||||||
|
|
@ -214,9 +218,8 @@ class TestResolveHostnamesBatch:
|
||||||
resolved_at=datetime.now(),
|
resolved_at=datetime.now(),
|
||||||
error_message="Name not found",
|
error_message="Name not found",
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("src.hosts.core.dns.resolve_hostname", mock_resolve_hostname):
|
|
||||||
|
|
||||||
|
with patch("src.hosts.core.dns.resolve_hostname", mock_resolve_hostname):
|
||||||
resolutions = await resolve_hostnames_batch(hostnames)
|
resolutions = await resolve_hostnames_batch(hostnames)
|
||||||
|
|
||||||
assert len(resolutions) == 2
|
assert len(resolutions) == 2
|
||||||
|
|
@ -278,16 +281,20 @@ class TestDNSService:
|
||||||
"""Test async resolution when service is enabled."""
|
"""Test async resolution when service is enabled."""
|
||||||
service = DNSService(enabled=True)
|
service = DNSService(enabled=True)
|
||||||
|
|
||||||
with patch("src.hosts.core.dns.resolve_hostname", new_callable=AsyncMock) as mock_resolve:
|
with patch(
|
||||||
|
"src.hosts.core.dns.resolve_hostname", new_callable=AsyncMock
|
||||||
|
) as mock_resolve:
|
||||||
mock_resolution = DNSResolution(
|
mock_resolution = DNSResolution(
|
||||||
hostname="example.com",
|
hostname="example.com",
|
||||||
resolved_ip="192.0.2.1",
|
resolved_ip="192.0.2.1",
|
||||||
status=DNSResolutionStatus.RESOLVED,
|
status=DNSResolutionStatus.RESOLVED,
|
||||||
resolved_at=datetime.now(),
|
resolved_at=datetime.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Use proper async setup
|
# Use proper async setup
|
||||||
async def mock_side_effect(hostname, timeout=5.0):
|
async def mock_side_effect(hostname, timeout=5.0):
|
||||||
return mock_resolution
|
return mock_resolution
|
||||||
|
|
||||||
mock_resolve.side_effect = mock_side_effect
|
mock_resolve.side_effect = mock_side_effect
|
||||||
|
|
||||||
resolution = await service.resolve_entry_async("example.com")
|
resolution = await service.resolve_entry_async("example.com")
|
||||||
|
|
@ -312,16 +319,20 @@ class TestDNSService:
|
||||||
"""Test manual entry refresh."""
|
"""Test manual entry refresh."""
|
||||||
service = DNSService(enabled=True)
|
service = DNSService(enabled=True)
|
||||||
|
|
||||||
with patch("src.hosts.core.dns.resolve_hostname", new_callable=AsyncMock) as mock_resolve:
|
with patch(
|
||||||
|
"src.hosts.core.dns.resolve_hostname", new_callable=AsyncMock
|
||||||
|
) as mock_resolve:
|
||||||
mock_resolution = DNSResolution(
|
mock_resolution = DNSResolution(
|
||||||
hostname="example.com",
|
hostname="example.com",
|
||||||
resolved_ip="192.0.2.1",
|
resolved_ip="192.0.2.1",
|
||||||
status=DNSResolutionStatus.RESOLVED,
|
status=DNSResolutionStatus.RESOLVED,
|
||||||
resolved_at=datetime.now(),
|
resolved_at=datetime.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Use proper async setup
|
# Use proper async setup
|
||||||
async def mock_side_effect(hostname, timeout=5.0):
|
async def mock_side_effect(hostname, timeout=5.0):
|
||||||
return mock_resolution
|
return mock_resolution
|
||||||
|
|
||||||
mock_resolve.side_effect = mock_side_effect
|
mock_resolve.side_effect = mock_side_effect
|
||||||
|
|
||||||
result = await service.refresh_entry("example.com")
|
result = await service.refresh_entry("example.com")
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from datetime import datetime, timedelta
|
||||||
from src.hosts.core.filters import EntryFilter, FilterOptions
|
from src.hosts.core.filters import EntryFilter, FilterOptions
|
||||||
from src.hosts.core.models import HostEntry
|
from src.hosts.core.models import HostEntry
|
||||||
|
|
||||||
|
|
||||||
class TestFilterOptions:
|
class TestFilterOptions:
|
||||||
"""Test FilterOptions dataclass."""
|
"""Test FilterOptions dataclass."""
|
||||||
|
|
||||||
|
|
@ -40,7 +41,7 @@ class TestFilterOptions:
|
||||||
active_only=True,
|
active_only=True,
|
||||||
dns_only=True,
|
dns_only=True,
|
||||||
search_term="test",
|
search_term="test",
|
||||||
preset_name="Active DNS Only"
|
preset_name="Active DNS Only",
|
||||||
)
|
)
|
||||||
assert options.active_only is True
|
assert options.active_only is True
|
||||||
assert options.dns_only is True
|
assert options.dns_only is True
|
||||||
|
|
@ -50,60 +51,58 @@ class TestFilterOptions:
|
||||||
def test_to_dict(self):
|
def test_to_dict(self):
|
||||||
"""Test converting FilterOptions to dictionary."""
|
"""Test converting FilterOptions to dictionary."""
|
||||||
options = FilterOptions(
|
options = FilterOptions(
|
||||||
active_only=True,
|
active_only=True, search_term="test", preset_name="Test Preset"
|
||||||
search_term="test",
|
|
||||||
preset_name="Test Preset"
|
|
||||||
)
|
)
|
||||||
result = options.to_dict()
|
result = options.to_dict()
|
||||||
|
|
||||||
expected = {
|
expected = {
|
||||||
'show_active': True,
|
"show_active": True,
|
||||||
'show_inactive': True,
|
"show_inactive": True,
|
||||||
'active_only': True,
|
"active_only": True,
|
||||||
'inactive_only': False,
|
"inactive_only": False,
|
||||||
'show_dns_entries': True,
|
"show_dns_entries": True,
|
||||||
'show_ip_entries': True,
|
"show_ip_entries": True,
|
||||||
'dns_only': False,
|
"dns_only": False,
|
||||||
'ip_only': False,
|
"ip_only": False,
|
||||||
'show_resolved': True,
|
"show_resolved": True,
|
||||||
'show_unresolved': True,
|
"show_unresolved": True,
|
||||||
'show_resolving': True,
|
"show_resolving": True,
|
||||||
'show_failed': True,
|
"show_failed": True,
|
||||||
'show_mismatched': True,
|
"show_mismatched": True,
|
||||||
'mismatch_only': False,
|
"mismatch_only": False,
|
||||||
'resolved_only': False,
|
"resolved_only": False,
|
||||||
'search_term': 'test',
|
"search_term": "test",
|
||||||
'search_in_hostnames': True,
|
"search_in_hostnames": True,
|
||||||
'search_in_comments': True,
|
"search_in_comments": True,
|
||||||
'search_in_ips': True,
|
"search_in_ips": True,
|
||||||
'case_sensitive': False,
|
"case_sensitive": False,
|
||||||
'preset_name': 'Test Preset'
|
"preset_name": "Test Preset",
|
||||||
}
|
}
|
||||||
|
|
||||||
assert result == expected
|
assert result == expected
|
||||||
|
|
||||||
def test_from_dict(self):
|
def test_from_dict(self):
|
||||||
"""Test creating FilterOptions from dictionary."""
|
"""Test creating FilterOptions from dictionary."""
|
||||||
data = {
|
data = {
|
||||||
'active_only': True,
|
"active_only": True,
|
||||||
'dns_only': True,
|
"dns_only": True,
|
||||||
'search_term': 'test',
|
"search_term": "test",
|
||||||
'preset_name': 'Test Preset'
|
"preset_name": "Test Preset",
|
||||||
}
|
}
|
||||||
|
|
||||||
options = FilterOptions.from_dict(data)
|
options = FilterOptions.from_dict(data)
|
||||||
assert options.active_only is True
|
assert options.active_only is True
|
||||||
assert options.dns_only is True
|
assert options.dns_only is True
|
||||||
assert options.search_term == 'test'
|
assert options.search_term == "test"
|
||||||
assert options.preset_name == 'Test Preset'
|
assert options.preset_name == "Test Preset"
|
||||||
# Verify missing keys use defaults
|
# Verify missing keys use defaults
|
||||||
assert options.inactive_only is False
|
assert options.inactive_only is False
|
||||||
|
|
||||||
def test_from_dict_partial(self):
|
def test_from_dict_partial(self):
|
||||||
"""Test creating FilterOptions from partial dictionary."""
|
"""Test creating FilterOptions from partial dictionary."""
|
||||||
data = {'active_only': True}
|
data = {"active_only": True}
|
||||||
options = FilterOptions.from_dict(data)
|
options = FilterOptions.from_dict(data)
|
||||||
|
|
||||||
assert options.active_only is True
|
assert options.active_only is True
|
||||||
assert options.inactive_only is False # Default value
|
assert options.inactive_only is False # Default value
|
||||||
assert options.search_term is None # Default value
|
assert options.search_term is None # Default value
|
||||||
|
|
@ -113,15 +112,16 @@ class TestFilterOptions:
|
||||||
# Default options should be empty
|
# Default options should be empty
|
||||||
options = FilterOptions()
|
options = FilterOptions()
|
||||||
assert options.is_empty() is True
|
assert options.is_empty() is True
|
||||||
|
|
||||||
# Options with search term should not be empty
|
# Options with search term should not be empty
|
||||||
options = FilterOptions(search_term="test")
|
options = FilterOptions(search_term="test")
|
||||||
assert options.is_empty() is False
|
assert options.is_empty() is False
|
||||||
|
|
||||||
# Options with any filter enabled should not be empty
|
# Options with any filter enabled should not be empty
|
||||||
options = FilterOptions(active_only=True)
|
options = FilterOptions(active_only=True)
|
||||||
assert options.is_empty() is False
|
assert options.is_empty() is False
|
||||||
|
|
||||||
|
|
||||||
class TestEntryFilter:
|
class TestEntryFilter:
|
||||||
"""Test EntryFilter class."""
|
"""Test EntryFilter class."""
|
||||||
|
|
||||||
|
|
@ -129,45 +129,45 @@ class TestEntryFilter:
|
||||||
def sample_entries(self):
|
def sample_entries(self):
|
||||||
"""Create sample entries for testing."""
|
"""Create sample entries for testing."""
|
||||||
entries = []
|
entries = []
|
||||||
|
|
||||||
# Active IP entry
|
# Active IP entry
|
||||||
entry1 = HostEntry("192.168.1.1", ["example.com"], "Test entry", True)
|
entry1 = HostEntry("192.168.1.1", ["example.com"], "Test entry", True)
|
||||||
entries.append(entry1)
|
entries.append(entry1)
|
||||||
|
|
||||||
# Inactive IP entry
|
# Inactive IP entry
|
||||||
entry2 = HostEntry("192.168.1.2", ["inactive.com"], "Inactive entry", False)
|
entry2 = HostEntry("192.168.1.2", ["inactive.com"], "Inactive entry", False)
|
||||||
entries.append(entry2)
|
entries.append(entry2)
|
||||||
|
|
||||||
# Active DNS entry - create with temporary IP then convert to DNS entry
|
# Active DNS entry - create with temporary IP then convert to DNS entry
|
||||||
entry3 = HostEntry("1.1.1.1", ["dns-only.com"], "DNS only entry", True)
|
entry3 = HostEntry("1.1.1.1", ["dns-only.com"], "DNS only entry", True)
|
||||||
entry3.ip_address = "" # Remove IP after creation
|
entry3.ip_address = "" # Remove IP after creation
|
||||||
entry3.dns_name = "dns-only.com" # Set DNS name
|
entry3.dns_name = "dns-only.com" # Set DNS name
|
||||||
entries.append(entry3)
|
entries.append(entry3)
|
||||||
|
|
||||||
# Inactive DNS entry - create with temporary IP then convert to DNS entry
|
# Inactive DNS entry - create with temporary IP then convert to DNS entry
|
||||||
entry4 = HostEntry("1.1.1.1", ["inactive-dns.com"], "Inactive DNS entry", False)
|
entry4 = HostEntry("1.1.1.1", ["inactive-dns.com"], "Inactive DNS entry", False)
|
||||||
entry4.ip_address = "" # Remove IP after creation
|
entry4.ip_address = "" # Remove IP after creation
|
||||||
entry4.dns_name = "inactive-dns.com" # Set DNS name
|
entry4.dns_name = "inactive-dns.com" # Set DNS name
|
||||||
entries.append(entry4)
|
entries.append(entry4)
|
||||||
|
|
||||||
# Entry with DNS resolution data
|
# Entry with DNS resolution data
|
||||||
entry5 = HostEntry("10.0.0.1", ["resolved.com"], "Resolved entry", True)
|
entry5 = HostEntry("10.0.0.1", ["resolved.com"], "Resolved entry", True)
|
||||||
entry5.resolved_ip = "10.0.0.1"
|
entry5.resolved_ip = "10.0.0.1"
|
||||||
entry5.last_resolved = datetime.now()
|
entry5.last_resolved = datetime.now()
|
||||||
entry5.dns_resolution_status = "IP_MATCH"
|
entry5.dns_resolution_status = "IP_MATCH"
|
||||||
entries.append(entry5)
|
entries.append(entry5)
|
||||||
|
|
||||||
# Entry with mismatched DNS
|
# Entry with mismatched DNS
|
||||||
entry6 = HostEntry("10.0.0.2", ["mismatch.com"], "Mismatch entry", True)
|
entry6 = HostEntry("10.0.0.2", ["mismatch.com"], "Mismatch entry", True)
|
||||||
entry6.resolved_ip = "10.0.0.3" # Different from IP address
|
entry6.resolved_ip = "10.0.0.3" # Different from IP address
|
||||||
entry6.last_resolved = datetime.now()
|
entry6.last_resolved = datetime.now()
|
||||||
entry6.dns_resolution_status = "IP_MISMATCH"
|
entry6.dns_resolution_status = "IP_MISMATCH"
|
||||||
entries.append(entry6)
|
entries.append(entry6)
|
||||||
|
|
||||||
# Entry without DNS resolution
|
# Entry without DNS resolution
|
||||||
entry7 = HostEntry("10.0.0.4", ["unresolved.com"], "Unresolved entry", True)
|
entry7 = HostEntry("10.0.0.4", ["unresolved.com"], "Unresolved entry", True)
|
||||||
entries.append(entry7)
|
entries.append(entry7)
|
||||||
|
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -186,7 +186,7 @@ class TestEntryFilter:
|
||||||
"""Test filtering by active status only."""
|
"""Test filtering by active status only."""
|
||||||
options = FilterOptions(active_only=True)
|
options = FilterOptions(active_only=True)
|
||||||
result = entry_filter.filter_by_status(sample_entries, options)
|
result = entry_filter.filter_by_status(sample_entries, options)
|
||||||
|
|
||||||
active_entries = [e for e in result if e.is_active]
|
active_entries = [e for e in result if e.is_active]
|
||||||
assert len(active_entries) == len(result)
|
assert len(active_entries) == len(result)
|
||||||
assert all(entry.is_active for entry in result)
|
assert all(entry.is_active for entry in result)
|
||||||
|
|
@ -195,7 +195,7 @@ class TestEntryFilter:
|
||||||
"""Test filtering by inactive status only."""
|
"""Test filtering by inactive status only."""
|
||||||
options = FilterOptions(inactive_only=True)
|
options = FilterOptions(inactive_only=True)
|
||||||
result = entry_filter.filter_by_status(sample_entries, options)
|
result = entry_filter.filter_by_status(sample_entries, options)
|
||||||
|
|
||||||
assert all(not entry.is_active for entry in result)
|
assert all(not entry.is_active for entry in result)
|
||||||
assert len(result) == 2 # entry2 and entry4
|
assert len(result) == 2 # entry2 and entry4
|
||||||
|
|
||||||
|
|
@ -203,7 +203,7 @@ class TestEntryFilter:
|
||||||
"""Test filtering by DNS entries only."""
|
"""Test filtering by DNS entries only."""
|
||||||
options = FilterOptions(dns_only=True)
|
options = FilterOptions(dns_only=True)
|
||||||
result = entry_filter.filter_by_dns_type(sample_entries, options)
|
result = entry_filter.filter_by_dns_type(sample_entries, options)
|
||||||
|
|
||||||
assert all(entry.dns_name is not None for entry in result)
|
assert all(entry.dns_name is not None for entry in result)
|
||||||
assert len(result) == 2 # entry3 and entry4
|
assert len(result) == 2 # entry3 and entry4
|
||||||
|
|
||||||
|
|
@ -211,7 +211,7 @@ class TestEntryFilter:
|
||||||
"""Test filtering by IP entries only."""
|
"""Test filtering by IP entries only."""
|
||||||
options = FilterOptions(ip_only=True)
|
options = FilterOptions(ip_only=True)
|
||||||
result = entry_filter.filter_by_dns_type(sample_entries, options)
|
result = entry_filter.filter_by_dns_type(sample_entries, options)
|
||||||
|
|
||||||
assert all(not entry.has_dns_name() for entry in result)
|
assert all(not entry.has_dns_name() for entry in result)
|
||||||
# Should exclude DNS-only entries (entry3, entry4)
|
# Should exclude DNS-only entries (entry3, entry4)
|
||||||
expected_count = len(sample_entries) - 2
|
expected_count = len(sample_entries) - 2
|
||||||
|
|
@ -221,8 +221,10 @@ class TestEntryFilter:
|
||||||
"""Test filtering by resolved entries only."""
|
"""Test filtering by resolved entries only."""
|
||||||
options = FilterOptions(resolved_only=True)
|
options = FilterOptions(resolved_only=True)
|
||||||
result = entry_filter.filter_by_resolution_status(sample_entries, options)
|
result = entry_filter.filter_by_resolution_status(sample_entries, options)
|
||||||
|
|
||||||
assert all(entry.dns_resolution_status in ["IP_MATCH", "RESOLVED"] for entry in result)
|
assert all(
|
||||||
|
entry.dns_resolution_status in ["IP_MATCH", "RESOLVED"] for entry in result
|
||||||
|
)
|
||||||
assert len(result) == 1 # Only entry5 has resolved status
|
assert len(result) == 1 # Only entry5 has resolved status
|
||||||
|
|
||||||
def test_filter_by_resolution_status_unresolved(self, entry_filter, sample_entries):
|
def test_filter_by_resolution_status_unresolved(self, entry_filter, sample_entries):
|
||||||
|
|
@ -231,18 +233,20 @@ class TestEntryFilter:
|
||||||
show_resolved=False,
|
show_resolved=False,
|
||||||
show_resolving=False,
|
show_resolving=False,
|
||||||
show_failed=False,
|
show_failed=False,
|
||||||
show_mismatched=False
|
show_mismatched=False,
|
||||||
)
|
)
|
||||||
result = entry_filter.filter_by_resolution_status(sample_entries, options)
|
result = entry_filter.filter_by_resolution_status(sample_entries, options)
|
||||||
|
|
||||||
assert all(entry.dns_resolution_status in [None, "NOT_RESOLVED"] for entry in result)
|
assert all(
|
||||||
|
entry.dns_resolution_status in [None, "NOT_RESOLVED"] for entry in result
|
||||||
|
)
|
||||||
assert len(result) == 5 # All except entry5 and entry6
|
assert len(result) == 5 # All except entry5 and entry6
|
||||||
|
|
||||||
def test_filter_by_resolution_status_mismatch(self, entry_filter, sample_entries):
|
def test_filter_by_resolution_status_mismatch(self, entry_filter, sample_entries):
|
||||||
"""Test filtering by DNS mismatch entries only."""
|
"""Test filtering by DNS mismatch entries only."""
|
||||||
options = FilterOptions(mismatch_only=True)
|
options = FilterOptions(mismatch_only=True)
|
||||||
result = entry_filter.filter_by_resolution_status(sample_entries, options)
|
result = entry_filter.filter_by_resolution_status(sample_entries, options)
|
||||||
|
|
||||||
# Should only return entry6 (mismatch between IP and resolved_ip)
|
# Should only return entry6 (mismatch between IP and resolved_ip)
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].hostnames[0] == "mismatch.com"
|
assert result[0].hostnames[0] == "mismatch.com"
|
||||||
|
|
@ -251,7 +255,7 @@ class TestEntryFilter:
|
||||||
"""Test filtering by search term in hostname."""
|
"""Test filtering by search term in hostname."""
|
||||||
options = FilterOptions(search_term="example")
|
options = FilterOptions(search_term="example")
|
||||||
result = entry_filter.filter_by_search(sample_entries, options)
|
result = entry_filter.filter_by_search(sample_entries, options)
|
||||||
|
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].hostnames[0] == "example.com"
|
assert result[0].hostnames[0] == "example.com"
|
||||||
|
|
||||||
|
|
@ -259,14 +263,14 @@ class TestEntryFilter:
|
||||||
"""Test filtering by search term in IP address."""
|
"""Test filtering by search term in IP address."""
|
||||||
options = FilterOptions(search_term="192.168")
|
options = FilterOptions(search_term="192.168")
|
||||||
result = entry_filter.filter_by_search(sample_entries, options)
|
result = entry_filter.filter_by_search(sample_entries, options)
|
||||||
|
|
||||||
assert len(result) == 2 # entry1 and entry2
|
assert len(result) == 2 # entry1 and entry2
|
||||||
|
|
||||||
def test_filter_by_search_comment(self, entry_filter, sample_entries):
|
def test_filter_by_search_comment(self, entry_filter, sample_entries):
|
||||||
"""Test filtering by search term in comment."""
|
"""Test filtering by search term in comment."""
|
||||||
options = FilterOptions(search_term="DNS only")
|
options = FilterOptions(search_term="DNS only")
|
||||||
result = entry_filter.filter_by_search(sample_entries, options)
|
result = entry_filter.filter_by_search(sample_entries, options)
|
||||||
|
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].comment == "DNS only entry"
|
assert result[0].comment == "DNS only entry"
|
||||||
|
|
||||||
|
|
@ -274,20 +278,16 @@ class TestEntryFilter:
|
||||||
"""Test search is case insensitive."""
|
"""Test search is case insensitive."""
|
||||||
options = FilterOptions(search_term="EXAMPLE")
|
options = FilterOptions(search_term="EXAMPLE")
|
||||||
result = entry_filter.filter_by_search(sample_entries, options)
|
result = entry_filter.filter_by_search(sample_entries, options)
|
||||||
|
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].hostnames[0] == "example.com"
|
assert result[0].hostnames[0] == "example.com"
|
||||||
|
|
||||||
def test_combined_filters(self, entry_filter, sample_entries):
|
def test_combined_filters(self, entry_filter, sample_entries):
|
||||||
"""Test applying multiple filters together."""
|
"""Test applying multiple filters together."""
|
||||||
# Filter for active DNS entries containing "dns"
|
# Filter for active DNS entries containing "dns"
|
||||||
options = FilterOptions(
|
options = FilterOptions(active_only=True, dns_only=True, search_term="dns")
|
||||||
active_only=True,
|
|
||||||
dns_only=True,
|
|
||||||
search_term="dns"
|
|
||||||
)
|
|
||||||
result = entry_filter.apply_filters(sample_entries, options)
|
result = entry_filter.apply_filters(sample_entries, options)
|
||||||
|
|
||||||
# Should only return entry3 (active DNS entry with "dns" in hostname)
|
# Should only return entry3 (active DNS entry with "dns" in hostname)
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].hostnames[0] == "dns-only.com"
|
assert result[0].hostnames[0] == "dns-only.com"
|
||||||
|
|
@ -298,14 +298,14 @@ class TestEntryFilter:
|
||||||
"""Test counting filtered entries."""
|
"""Test counting filtered entries."""
|
||||||
options = FilterOptions(active_only=True)
|
options = FilterOptions(active_only=True)
|
||||||
counts = entry_filter.count_filtered_entries(sample_entries, options)
|
counts = entry_filter.count_filtered_entries(sample_entries, options)
|
||||||
|
|
||||||
assert counts['total'] == len(sample_entries)
|
assert counts["total"] == len(sample_entries)
|
||||||
assert counts['filtered'] == 5 # 5 active entries
|
assert counts["filtered"] == 5 # 5 active entries
|
||||||
|
|
||||||
def test_get_default_presets(self, entry_filter):
|
def test_get_default_presets(self, entry_filter):
|
||||||
"""Test getting default filter presets."""
|
"""Test getting default filter presets."""
|
||||||
presets = entry_filter.get_default_presets()
|
presets = entry_filter.get_default_presets()
|
||||||
|
|
||||||
# Check that default presets exist
|
# Check that default presets exist
|
||||||
assert "All Entries" in presets
|
assert "All Entries" in presets
|
||||||
assert "Active Only" in presets
|
assert "Active Only" in presets
|
||||||
|
|
@ -315,7 +315,7 @@ class TestEntryFilter:
|
||||||
assert "DNS Mismatches" in presets
|
assert "DNS Mismatches" in presets
|
||||||
assert "Resolved Entries" in presets
|
assert "Resolved Entries" in presets
|
||||||
assert "Unresolved Entries" in presets
|
assert "Unresolved Entries" in presets
|
||||||
|
|
||||||
# Check that presets have correct structure
|
# Check that presets have correct structure
|
||||||
for preset_name, options in presets.items():
|
for preset_name, options in presets.items():
|
||||||
assert isinstance(options, FilterOptions)
|
assert isinstance(options, FilterOptions)
|
||||||
|
|
@ -324,18 +324,16 @@ class TestEntryFilter:
|
||||||
"""Test saving and loading custom presets."""
|
"""Test saving and loading custom presets."""
|
||||||
# Create custom filter options
|
# Create custom filter options
|
||||||
custom_options = FilterOptions(
|
custom_options = FilterOptions(
|
||||||
active_only=True,
|
active_only=True, search_term="test", preset_name="My Custom Filter"
|
||||||
search_term="test",
|
|
||||||
preset_name="My Custom Filter"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save preset
|
# Save preset
|
||||||
entry_filter.save_preset("My Custom Filter", custom_options)
|
entry_filter.save_preset("My Custom Filter", custom_options)
|
||||||
|
|
||||||
# Check it was saved
|
# Check it was saved
|
||||||
presets = entry_filter.get_saved_presets()
|
presets = entry_filter.get_saved_presets()
|
||||||
assert "My Custom Filter" in presets
|
assert "My Custom Filter" in presets
|
||||||
|
|
||||||
# Load and verify
|
# Load and verify
|
||||||
loaded_options = presets["My Custom Filter"]
|
loaded_options = presets["My Custom Filter"]
|
||||||
assert loaded_options.active_only is True
|
assert loaded_options.active_only is True
|
||||||
|
|
@ -347,19 +345,19 @@ class TestEntryFilter:
|
||||||
# Save a preset first
|
# Save a preset first
|
||||||
custom_options = FilterOptions(active_only=True)
|
custom_options = FilterOptions(active_only=True)
|
||||||
entry_filter.save_preset("To Delete", custom_options)
|
entry_filter.save_preset("To Delete", custom_options)
|
||||||
|
|
||||||
# Verify it exists
|
# Verify it exists
|
||||||
presets = entry_filter.get_saved_presets()
|
presets = entry_filter.get_saved_presets()
|
||||||
assert "To Delete" in presets
|
assert "To Delete" in presets
|
||||||
|
|
||||||
# Delete it
|
# Delete it
|
||||||
result = entry_filter.delete_preset("To Delete")
|
result = entry_filter.delete_preset("To Delete")
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
# Verify it's gone
|
# Verify it's gone
|
||||||
presets = entry_filter.get_saved_presets()
|
presets = entry_filter.get_saved_presets()
|
||||||
assert "To Delete" not in presets
|
assert "To Delete" not in presets
|
||||||
|
|
||||||
# Try to delete non-existent preset
|
# Try to delete non-existent preset
|
||||||
result = entry_filter.delete_preset("Non Existent")
|
result = entry_filter.delete_preset("Non Existent")
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
@ -370,7 +368,7 @@ class TestEntryFilter:
|
||||||
empty_options = FilterOptions()
|
empty_options = FilterOptions()
|
||||||
result = entry_filter.apply_filters([], empty_options)
|
result = entry_filter.apply_filters([], empty_options)
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
# None entries in list - filtering should handle None values gracefully
|
# None entries in list - filtering should handle None values gracefully
|
||||||
entries_with_none = [None, HostEntry("192.168.1.1", ["test.com"], "", True)]
|
entries_with_none = [None, HostEntry("192.168.1.1", ["test.com"], "", True)]
|
||||||
# Filter out None values before applying filters
|
# Filter out None values before applying filters
|
||||||
|
|
@ -382,9 +380,14 @@ class TestEntryFilter:
|
||||||
def test_search_multiple_hostnames(self, entry_filter):
|
def test_search_multiple_hostnames(self, entry_filter):
|
||||||
"""Test search across multiple hostnames in single entry."""
|
"""Test search across multiple hostnames in single entry."""
|
||||||
# Create entry with multiple hostnames
|
# Create entry with multiple hostnames
|
||||||
entry = HostEntry("192.168.1.1", ["primary.com", "secondary.com", "alias.org"], "Multi-hostname entry", True)
|
entry = HostEntry(
|
||||||
|
"192.168.1.1",
|
||||||
|
["primary.com", "secondary.com", "alias.org"],
|
||||||
|
"Multi-hostname entry",
|
||||||
|
True,
|
||||||
|
)
|
||||||
entries = [entry]
|
entries = [entry]
|
||||||
|
|
||||||
# Search for each hostname
|
# Search for each hostname
|
||||||
for hostname in ["primary", "secondary", "alias"]:
|
for hostname in ["primary", "secondary", "alias"]:
|
||||||
options = FilterOptions(search_term=hostname)
|
options = FilterOptions(search_term=hostname)
|
||||||
|
|
@ -397,7 +400,7 @@ class TestEntryFilter:
|
||||||
# Modify sample entries to have different resolution times
|
# Modify sample entries to have different resolution times
|
||||||
old_time = datetime.now() - timedelta(days=1)
|
old_time = datetime.now() - timedelta(days=1)
|
||||||
recent_time = datetime.now() - timedelta(minutes=5)
|
recent_time = datetime.now() - timedelta(minutes=5)
|
||||||
|
|
||||||
# Make one entry have old resolution
|
# Make one entry have old resolution
|
||||||
for entry in sample_entries:
|
for entry in sample_entries:
|
||||||
if entry.resolved_ip:
|
if entry.resolved_ip:
|
||||||
|
|
@ -405,7 +408,7 @@ class TestEntryFilter:
|
||||||
entry.last_resolved = recent_time
|
entry.last_resolved = recent_time
|
||||||
else:
|
else:
|
||||||
entry.last_resolved = old_time
|
entry.last_resolved = old_time
|
||||||
|
|
||||||
# Test that entries are still found regardless of age
|
# Test that entries are still found regardless of age
|
||||||
# (Age filtering might be added in future versions)
|
# (Age filtering might be added in future versions)
|
||||||
options = FilterOptions(resolved_only=True)
|
options = FilterOptions(resolved_only=True)
|
||||||
|
|
@ -414,14 +417,11 @@ class TestEntryFilter:
|
||||||
|
|
||||||
def test_preset_name_preservation(self, entry_filter):
|
def test_preset_name_preservation(self, entry_filter):
|
||||||
"""Test that preset names are preserved in FilterOptions."""
|
"""Test that preset names are preserved in FilterOptions."""
|
||||||
preset_options = FilterOptions(
|
preset_options = FilterOptions(active_only=True, preset_name="Active Only")
|
||||||
active_only=True,
|
|
||||||
preset_name="Active Only"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply filters and check preset name is preserved
|
# Apply filters and check preset name is preserved
|
||||||
sample_entry = HostEntry("192.168.1.1", ["test.com"], "Test", True)
|
sample_entry = HostEntry("192.168.1.1", ["test.com"], "Test", True)
|
||||||
entry_filter.apply_filters([sample_entry], preset_options)
|
entry_filter.apply_filters([sample_entry], preset_options)
|
||||||
|
|
||||||
# The original preset name should be accessible
|
# The original preset name should be accessible
|
||||||
assert preset_options.preset_name == "Active Only"
|
assert preset_options.preset_name == "Active Only"
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,14 @@ import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from src.hosts.core.import_export import (
|
from src.hosts.core.import_export import (
|
||||||
ImportExportService, ImportResult, ExportFormat, ImportFormat
|
ImportExportService,
|
||||||
|
ImportResult,
|
||||||
|
ExportFormat,
|
||||||
|
ImportFormat,
|
||||||
)
|
)
|
||||||
from src.hosts.core.models import HostEntry, HostsFile
|
from src.hosts.core.models import HostEntry, HostsFile
|
||||||
|
|
||||||
|
|
||||||
class TestImportExportService:
|
class TestImportExportService:
|
||||||
"""Test ImportExportService class."""
|
"""Test ImportExportService class."""
|
||||||
|
|
||||||
|
|
@ -31,16 +35,16 @@ class TestImportExportService:
|
||||||
HostEntry("127.0.0.1", ["localhost"], "Local host", True),
|
HostEntry("127.0.0.1", ["localhost"], "Local host", True),
|
||||||
HostEntry("192.168.1.1", ["router.local"], "Home router", True),
|
HostEntry("192.168.1.1", ["router.local"], "Home router", True),
|
||||||
HostEntry("1.1.1.1", ["dns-only.com"], "DNS only entry", False), # Temp IP
|
HostEntry("1.1.1.1", ["dns-only.com"], "DNS only entry", False), # Temp IP
|
||||||
HostEntry("10.0.0.1", ["test.example.com"], "Test server", True)
|
HostEntry("10.0.0.1", ["test.example.com"], "Test server", True),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Convert to DNS entry and set DNS data for some entries
|
# Convert to DNS entry and set DNS data for some entries
|
||||||
entries[2].ip_address = "" # Remove IP after creation
|
entries[2].ip_address = "" # Remove IP after creation
|
||||||
entries[2].dns_name = "dns-only.com"
|
entries[2].dns_name = "dns-only.com"
|
||||||
entries[3].resolved_ip = "10.0.0.1"
|
entries[3].resolved_ip = "10.0.0.1"
|
||||||
entries[3].last_resolved = datetime(2024, 1, 15, 12, 0, 0)
|
entries[3].last_resolved = datetime(2024, 1, 15, 12, 0, 0)
|
||||||
entries[3].dns_resolution_status = "IP_MATCH"
|
entries[3].dns_resolution_status = "IP_MATCH"
|
||||||
|
|
||||||
hosts_file = HostsFile()
|
hosts_file = HostsFile()
|
||||||
hosts_file.entries = entries
|
hosts_file.entries = entries
|
||||||
return hosts_file
|
return hosts_file
|
||||||
|
|
@ -63,7 +67,7 @@ class TestImportExportService:
|
||||||
"""Test getting supported formats."""
|
"""Test getting supported formats."""
|
||||||
export_formats = service.get_supported_export_formats()
|
export_formats = service.get_supported_export_formats()
|
||||||
import_formats = service.get_supported_import_formats()
|
import_formats = service.get_supported_import_formats()
|
||||||
|
|
||||||
assert len(export_formats) == 3
|
assert len(export_formats) == 3
|
||||||
assert len(import_formats) == 3
|
assert len(import_formats) == 3
|
||||||
assert ExportFormat.HOSTS in export_formats
|
assert ExportFormat.HOSTS in export_formats
|
||||||
|
|
@ -74,15 +78,15 @@ class TestImportExportService:
|
||||||
def test_export_hosts_format(self, service, sample_hosts_file, temp_dir):
|
def test_export_hosts_format(self, service, sample_hosts_file, temp_dir):
|
||||||
"""Test exporting to hosts format."""
|
"""Test exporting to hosts format."""
|
||||||
export_path = temp_dir / "test_hosts.txt"
|
export_path = temp_dir / "test_hosts.txt"
|
||||||
|
|
||||||
result = service.export_hosts_format(sample_hosts_file, export_path)
|
result = service.export_hosts_format(sample_hosts_file, export_path)
|
||||||
|
|
||||||
assert result.success is True
|
assert result.success is True
|
||||||
assert result.entries_exported == 4
|
assert result.entries_exported == 4
|
||||||
assert len(result.errors) == 0
|
assert len(result.errors) == 0
|
||||||
assert result.format == ExportFormat.HOSTS
|
assert result.format == ExportFormat.HOSTS
|
||||||
assert export_path.exists()
|
assert export_path.exists()
|
||||||
|
|
||||||
# Verify content
|
# Verify content
|
||||||
content = export_path.read_text()
|
content = export_path.read_text()
|
||||||
assert "127.0.0.1" in content
|
assert "127.0.0.1" in content
|
||||||
|
|
@ -92,30 +96,30 @@ class TestImportExportService:
|
||||||
def test_export_json_format(self, service, sample_hosts_file, temp_dir):
|
def test_export_json_format(self, service, sample_hosts_file, temp_dir):
|
||||||
"""Test exporting to JSON format."""
|
"""Test exporting to JSON format."""
|
||||||
export_path = temp_dir / "test_export.json"
|
export_path = temp_dir / "test_export.json"
|
||||||
|
|
||||||
result = service.export_json_format(sample_hosts_file, export_path)
|
result = service.export_json_format(sample_hosts_file, export_path)
|
||||||
|
|
||||||
assert result.success is True
|
assert result.success is True
|
||||||
assert result.entries_exported == 4
|
assert result.entries_exported == 4
|
||||||
assert len(result.errors) == 0
|
assert len(result.errors) == 0
|
||||||
assert result.format == ExportFormat.JSON
|
assert result.format == ExportFormat.JSON
|
||||||
assert export_path.exists()
|
assert export_path.exists()
|
||||||
|
|
||||||
# Verify JSON structure
|
# Verify JSON structure
|
||||||
with open(export_path, 'r') as f:
|
with open(export_path, "r") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
|
|
||||||
assert "metadata" in data
|
assert "metadata" in data
|
||||||
assert "entries" in data
|
assert "entries" in data
|
||||||
assert data["metadata"]["total_entries"] == 4
|
assert data["metadata"]["total_entries"] == 4
|
||||||
assert len(data["entries"]) == 4
|
assert len(data["entries"]) == 4
|
||||||
|
|
||||||
# Check first entry
|
# Check first entry
|
||||||
first_entry = data["entries"][0]
|
first_entry = data["entries"][0]
|
||||||
assert first_entry["ip_address"] == "127.0.0.1"
|
assert first_entry["ip_address"] == "127.0.0.1"
|
||||||
assert first_entry["hostnames"] == ["localhost"]
|
assert first_entry["hostnames"] == ["localhost"]
|
||||||
assert first_entry["is_active"] is True
|
assert first_entry["is_active"] is True
|
||||||
|
|
||||||
# Check DNS entry
|
# Check DNS entry
|
||||||
dns_entry = next((e for e in data["entries"] if e.get("dns_name")), None)
|
dns_entry = next((e for e in data["entries"] if e.get("dns_name")), None)
|
||||||
assert dns_entry is not None
|
assert dns_entry is not None
|
||||||
|
|
@ -124,29 +128,35 @@ class TestImportExportService:
|
||||||
def test_export_csv_format(self, service, sample_hosts_file, temp_dir):
|
def test_export_csv_format(self, service, sample_hosts_file, temp_dir):
|
||||||
"""Test exporting to CSV format."""
|
"""Test exporting to CSV format."""
|
||||||
export_path = temp_dir / "test_export.csv"
|
export_path = temp_dir / "test_export.csv"
|
||||||
|
|
||||||
result = service.export_csv_format(sample_hosts_file, export_path)
|
result = service.export_csv_format(sample_hosts_file, export_path)
|
||||||
|
|
||||||
assert result.success is True
|
assert result.success is True
|
||||||
assert result.entries_exported == 4
|
assert result.entries_exported == 4
|
||||||
assert len(result.errors) == 0
|
assert len(result.errors) == 0
|
||||||
assert result.format == ExportFormat.CSV
|
assert result.format == ExportFormat.CSV
|
||||||
assert export_path.exists()
|
assert export_path.exists()
|
||||||
|
|
||||||
# Verify CSV structure
|
# Verify CSV structure
|
||||||
with open(export_path, 'r') as f:
|
with open(export_path, "r") as f:
|
||||||
reader = csv.DictReader(f)
|
reader = csv.DictReader(f)
|
||||||
rows = list(reader)
|
rows = list(reader)
|
||||||
|
|
||||||
assert len(rows) == 4
|
assert len(rows) == 4
|
||||||
|
|
||||||
# Check header
|
# Check header
|
||||||
expected_fields = [
|
expected_fields = [
|
||||||
'ip_address', 'hostnames', 'comment', 'is_active',
|
"ip_address",
|
||||||
'dns_name', 'resolved_ip', 'last_resolved', 'dns_resolution_status'
|
"hostnames",
|
||||||
|
"comment",
|
||||||
|
"is_active",
|
||||||
|
"dns_name",
|
||||||
|
"resolved_ip",
|
||||||
|
"last_resolved",
|
||||||
|
"dns_resolution_status",
|
||||||
]
|
]
|
||||||
assert reader.fieldnames == expected_fields
|
assert reader.fieldnames == expected_fields
|
||||||
|
|
||||||
# Check first row
|
# Check first row
|
||||||
first_row = rows[0]
|
first_row = rows[0]
|
||||||
assert first_row["ip_address"] == "127.0.0.1"
|
assert first_row["ip_address"] == "127.0.0.1"
|
||||||
|
|
@ -156,9 +166,9 @@ class TestImportExportService:
|
||||||
def test_export_invalid_path(self, service, sample_hosts_file):
|
def test_export_invalid_path(self, service, sample_hosts_file):
|
||||||
"""Test export with invalid path."""
|
"""Test export with invalid path."""
|
||||||
invalid_path = Path("/invalid/path/test.json")
|
invalid_path = Path("/invalid/path/test.json")
|
||||||
|
|
||||||
result = service.export_json_format(sample_hosts_file, invalid_path)
|
result = service.export_json_format(sample_hosts_file, invalid_path)
|
||||||
|
|
||||||
assert result.success is False
|
assert result.success is False
|
||||||
assert result.entries_exported == 0
|
assert result.entries_exported == 0
|
||||||
assert len(result.errors) > 0
|
assert len(result.errors) > 0
|
||||||
|
|
@ -176,17 +186,19 @@ class TestImportExportService:
|
||||||
"""
|
"""
|
||||||
hosts_path = temp_dir / "test_hosts.txt"
|
hosts_path = temp_dir / "test_hosts.txt"
|
||||||
hosts_path.write_text(hosts_content)
|
hosts_path.write_text(hosts_content)
|
||||||
|
|
||||||
result = service.import_hosts_format(hosts_path)
|
result = service.import_hosts_format(hosts_path)
|
||||||
|
|
||||||
assert result.success is True
|
assert result.success is True
|
||||||
assert result.total_processed >= 2
|
assert result.total_processed >= 2
|
||||||
assert result.successfully_imported >= 2
|
assert result.successfully_imported >= 2
|
||||||
assert len(result.errors) == 0
|
assert len(result.errors) == 0
|
||||||
|
|
||||||
# Check imported entries
|
# Check imported entries
|
||||||
assert len(result.entries) >= 2
|
assert len(result.entries) >= 2
|
||||||
localhost_entry = next((e for e in result.entries if "localhost" in e.hostnames), None)
|
localhost_entry = next(
|
||||||
|
(e for e in result.entries if "localhost" in e.hostnames), None
|
||||||
|
)
|
||||||
assert localhost_entry is not None
|
assert localhost_entry is not None
|
||||||
assert localhost_entry.ip_address == "127.0.0.1"
|
assert localhost_entry.ip_address == "127.0.0.1"
|
||||||
assert localhost_entry.is_active is True
|
assert localhost_entry.is_active is True
|
||||||
|
|
@ -198,21 +210,21 @@ class TestImportExportService:
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"exported_at": "2024-01-15T12:00:00",
|
"exported_at": "2024-01-15T12:00:00",
|
||||||
"total_entries": 3,
|
"total_entries": 3,
|
||||||
"version": "1.0"
|
"version": "1.0",
|
||||||
},
|
},
|
||||||
"entries": [
|
"entries": [
|
||||||
{
|
{
|
||||||
"ip_address": "127.0.0.1",
|
"ip_address": "127.0.0.1",
|
||||||
"hostnames": ["localhost"],
|
"hostnames": ["localhost"],
|
||||||
"comment": "Local host",
|
"comment": "Local host",
|
||||||
"is_active": True
|
"is_active": True,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ip_address": "",
|
"ip_address": "",
|
||||||
"hostnames": ["dns-only.com"],
|
"hostnames": ["dns-only.com"],
|
||||||
"comment": "DNS only",
|
"comment": "DNS only",
|
||||||
"is_active": False,
|
"is_active": False,
|
||||||
"dns_name": "dns-only.com"
|
"dns_name": "dns-only.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ip_address": "10.0.0.1",
|
"ip_address": "10.0.0.1",
|
||||||
|
|
@ -221,29 +233,29 @@ class TestImportExportService:
|
||||||
"is_active": True,
|
"is_active": True,
|
||||||
"resolved_ip": "10.0.0.1",
|
"resolved_ip": "10.0.0.1",
|
||||||
"last_resolved": "2024-01-15T12:00:00",
|
"last_resolved": "2024-01-15T12:00:00",
|
||||||
"dns_resolution_status": "IP_MATCH"
|
"dns_resolution_status": "IP_MATCH",
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
json_path = temp_dir / "test_import.json"
|
json_path = temp_dir / "test_import.json"
|
||||||
with open(json_path, 'w') as f:
|
with open(json_path, "w") as f:
|
||||||
json.dump(json_data, f)
|
json.dump(json_data, f)
|
||||||
|
|
||||||
result = service.import_json_format(json_path)
|
result = service.import_json_format(json_path)
|
||||||
|
|
||||||
assert result.success is True
|
assert result.success is True
|
||||||
assert result.total_processed == 3
|
assert result.total_processed == 3
|
||||||
assert result.successfully_imported == 3
|
assert result.successfully_imported == 3
|
||||||
assert len(result.errors) == 0
|
assert len(result.errors) == 0
|
||||||
assert len(result.entries) == 3
|
assert len(result.entries) == 3
|
||||||
|
|
||||||
# Check DNS entry
|
# Check DNS entry
|
||||||
dns_entry = next((e for e in result.entries if e.dns_name), None)
|
dns_entry = next((e for e in result.entries if e.dns_name), None)
|
||||||
assert dns_entry is not None
|
assert dns_entry is not None
|
||||||
assert dns_entry.dns_name == "dns-only.com"
|
assert dns_entry.dns_name == "dns-only.com"
|
||||||
assert dns_entry.ip_address == ""
|
assert dns_entry.ip_address == ""
|
||||||
|
|
||||||
# Check resolved entry
|
# Check resolved entry
|
||||||
resolved_entry = next((e for e in result.entries if e.resolved_ip), None)
|
resolved_entry = next((e for e in result.entries if e.resolved_ip), None)
|
||||||
assert resolved_entry is not None
|
assert resolved_entry is not None
|
||||||
|
|
@ -254,35 +266,51 @@ class TestImportExportService:
|
||||||
"""Test importing from CSV format."""
|
"""Test importing from CSV format."""
|
||||||
# Create test CSV file
|
# Create test CSV file
|
||||||
csv_path = temp_dir / "test_import.csv"
|
csv_path = temp_dir / "test_import.csv"
|
||||||
with open(csv_path, 'w', newline='') as f:
|
with open(csv_path, "w", newline="") as f:
|
||||||
writer = csv.writer(f)
|
writer = csv.writer(f)
|
||||||
writer.writerow([
|
writer.writerow(
|
||||||
'ip_address', 'hostnames', 'comment', 'is_active',
|
[
|
||||||
'dns_name', 'resolved_ip', 'last_resolved', 'dns_resolution_status'
|
"ip_address",
|
||||||
])
|
"hostnames",
|
||||||
writer.writerow([
|
"comment",
|
||||||
'127.0.0.1', 'localhost', 'Local host', 'true',
|
"is_active",
|
||||||
'', '', '', ''
|
"dns_name",
|
||||||
])
|
"resolved_ip",
|
||||||
writer.writerow([
|
"last_resolved",
|
||||||
'', 'dns-only.com', 'DNS only', 'false',
|
"dns_resolution_status",
|
||||||
'dns-only.com', '', '', ''
|
]
|
||||||
])
|
)
|
||||||
writer.writerow([
|
writer.writerow(
|
||||||
'10.0.0.1', 'test.com example.com', 'Test server', 'true',
|
["127.0.0.1", "localhost", "Local host", "true", "", "", "", ""]
|
||||||
'', '10.0.0.1', '2024-01-15T12:00:00', 'IP_MATCH'
|
)
|
||||||
])
|
writer.writerow(
|
||||||
|
["", "dns-only.com", "DNS only", "false", "dns-only.com", "", "", ""]
|
||||||
|
)
|
||||||
|
writer.writerow(
|
||||||
|
[
|
||||||
|
"10.0.0.1",
|
||||||
|
"test.com example.com",
|
||||||
|
"Test server",
|
||||||
|
"true",
|
||||||
|
"",
|
||||||
|
"10.0.0.1",
|
||||||
|
"2024-01-15T12:00:00",
|
||||||
|
"IP_MATCH",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
result = service.import_csv_format(csv_path)
|
result = service.import_csv_format(csv_path)
|
||||||
|
|
||||||
assert result.success is True
|
assert result.success is True
|
||||||
assert result.total_processed == 3
|
assert result.total_processed == 3
|
||||||
assert result.successfully_imported == 3
|
assert result.successfully_imported == 3
|
||||||
assert len(result.errors) == 0
|
assert len(result.errors) == 0
|
||||||
assert len(result.entries) == 3
|
assert len(result.entries) == 3
|
||||||
|
|
||||||
# Check multiple hostnames entry
|
# Check multiple hostnames entry
|
||||||
multi_hostname_entry = next((e for e in result.entries if "test.com" in e.hostnames), None)
|
multi_hostname_entry = next(
|
||||||
|
(e for e in result.entries if "test.com" in e.hostnames), None
|
||||||
|
)
|
||||||
assert multi_hostname_entry is not None
|
assert multi_hostname_entry is not None
|
||||||
assert "example.com" in multi_hostname_entry.hostnames
|
assert "example.com" in multi_hostname_entry.hostnames
|
||||||
assert len(multi_hostname_entry.hostnames) == 2
|
assert len(multi_hostname_entry.hostnames) == 2
|
||||||
|
|
@ -292,11 +320,11 @@ class TestImportExportService:
|
||||||
# Create invalid JSON file
|
# Create invalid JSON file
|
||||||
invalid_json = {"invalid": "format", "no_entries": True}
|
invalid_json = {"invalid": "format", "no_entries": True}
|
||||||
json_path = temp_dir / "invalid.json"
|
json_path = temp_dir / "invalid.json"
|
||||||
with open(json_path, 'w') as f:
|
with open(json_path, "w") as f:
|
||||||
json.dump(invalid_json, f)
|
json.dump(invalid_json, f)
|
||||||
|
|
||||||
result = service.import_json_format(json_path)
|
result = service.import_json_format(json_path)
|
||||||
|
|
||||||
assert result.success is False
|
assert result.success is False
|
||||||
assert result.total_processed == 0
|
assert result.total_processed == 0
|
||||||
assert result.successfully_imported == 0
|
assert result.successfully_imported == 0
|
||||||
|
|
@ -307,9 +335,9 @@ class TestImportExportService:
|
||||||
"""Test importing malformed JSON."""
|
"""Test importing malformed JSON."""
|
||||||
json_path = temp_dir / "malformed.json"
|
json_path = temp_dir / "malformed.json"
|
||||||
json_path.write_text("{invalid json content")
|
json_path.write_text("{invalid json content")
|
||||||
|
|
||||||
result = service.import_json_format(json_path)
|
result = service.import_json_format(json_path)
|
||||||
|
|
||||||
assert result.success is False
|
assert result.success is False
|
||||||
assert result.total_processed == 0
|
assert result.total_processed == 0
|
||||||
assert result.successfully_imported == 0
|
assert result.successfully_imported == 0
|
||||||
|
|
@ -319,13 +347,13 @@ class TestImportExportService:
|
||||||
def test_import_csv_missing_required_columns(self, service, temp_dir):
|
def test_import_csv_missing_required_columns(self, service, temp_dir):
|
||||||
"""Test importing CSV with missing required columns."""
|
"""Test importing CSV with missing required columns."""
|
||||||
csv_path = temp_dir / "missing_columns.csv"
|
csv_path = temp_dir / "missing_columns.csv"
|
||||||
with open(csv_path, 'w', newline='') as f:
|
with open(csv_path, "w", newline="") as f:
|
||||||
writer = csv.writer(f)
|
writer = csv.writer(f)
|
||||||
writer.writerow(['ip_address', 'comment']) # Missing 'hostnames'
|
writer.writerow(["ip_address", "comment"]) # Missing 'hostnames'
|
||||||
writer.writerow(['127.0.0.1', 'test'])
|
writer.writerow(["127.0.0.1", "test"])
|
||||||
|
|
||||||
result = service.import_csv_format(csv_path)
|
result = service.import_csv_format(csv_path)
|
||||||
|
|
||||||
assert result.success is False
|
assert result.success is False
|
||||||
assert result.total_processed == 0
|
assert result.total_processed == 0
|
||||||
assert result.successfully_imported == 0
|
assert result.successfully_imported == 0
|
||||||
|
|
@ -341,17 +369,17 @@ class TestImportExportService:
|
||||||
"hostnames": ["localhost"],
|
"hostnames": ["localhost"],
|
||||||
"comment": "Test",
|
"comment": "Test",
|
||||||
"is_active": True,
|
"is_active": True,
|
||||||
"last_resolved": "invalid-date-format"
|
"last_resolved": "invalid-date-format",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
json_path = temp_dir / "warnings.json"
|
json_path = temp_dir / "warnings.json"
|
||||||
with open(json_path, 'w') as f:
|
with open(json_path, "w") as f:
|
||||||
json.dump(json_data, f)
|
json.dump(json_data, f)
|
||||||
|
|
||||||
result = service.import_json_format(json_path)
|
result = service.import_json_format(json_path)
|
||||||
|
|
||||||
assert result.success is True
|
assert result.success is True
|
||||||
assert result.total_processed == 1
|
assert result.total_processed == 1
|
||||||
assert result.successfully_imported == 1
|
assert result.successfully_imported == 1
|
||||||
|
|
@ -361,9 +389,9 @@ class TestImportExportService:
|
||||||
def test_import_nonexistent_file(self, service):
|
def test_import_nonexistent_file(self, service):
|
||||||
"""Test importing non-existent file."""
|
"""Test importing non-existent file."""
|
||||||
nonexistent_path = Path("/nonexistent/file.json")
|
nonexistent_path = Path("/nonexistent/file.json")
|
||||||
|
|
||||||
result = service.import_json_format(nonexistent_path)
|
result = service.import_json_format(nonexistent_path)
|
||||||
|
|
||||||
assert result.success is False
|
assert result.success is False
|
||||||
assert result.total_processed == 0
|
assert result.total_processed == 0
|
||||||
assert result.successfully_imported == 0
|
assert result.successfully_imported == 0
|
||||||
|
|
@ -377,11 +405,11 @@ class TestImportExportService:
|
||||||
csv_file = temp_dir / "test.csv"
|
csv_file = temp_dir / "test.csv"
|
||||||
hosts_file = temp_dir / "hosts"
|
hosts_file = temp_dir / "hosts"
|
||||||
txt_file = temp_dir / "test.txt"
|
txt_file = temp_dir / "test.txt"
|
||||||
|
|
||||||
# Create empty files
|
# Create empty files
|
||||||
for f in [json_file, csv_file, hosts_file, txt_file]:
|
for f in [json_file, csv_file, hosts_file, txt_file]:
|
||||||
f.touch()
|
f.touch()
|
||||||
|
|
||||||
assert service.detect_file_format(json_file) == ImportFormat.JSON
|
assert service.detect_file_format(json_file) == ImportFormat.JSON
|
||||||
assert service.detect_file_format(csv_file) == ImportFormat.CSV
|
assert service.detect_file_format(csv_file) == ImportFormat.CSV
|
||||||
assert service.detect_file_format(hosts_file) == ImportFormat.HOSTS
|
assert service.detect_file_format(hosts_file) == ImportFormat.HOSTS
|
||||||
|
|
@ -393,15 +421,15 @@ class TestImportExportService:
|
||||||
json_file = temp_dir / "no_extension"
|
json_file = temp_dir / "no_extension"
|
||||||
json_file.write_text('{"entries": []}')
|
json_file.write_text('{"entries": []}')
|
||||||
assert service.detect_file_format(json_file) == ImportFormat.JSON
|
assert service.detect_file_format(json_file) == ImportFormat.JSON
|
||||||
|
|
||||||
# CSV content
|
# CSV content
|
||||||
csv_file = temp_dir / "csv_no_ext"
|
csv_file = temp_dir / "csv_no_ext"
|
||||||
csv_file.write_text('ip_address,hostnames,comment')
|
csv_file.write_text("ip_address,hostnames,comment")
|
||||||
assert service.detect_file_format(csv_file) == ImportFormat.CSV
|
assert service.detect_file_format(csv_file) == ImportFormat.CSV
|
||||||
|
|
||||||
# Hosts content
|
# Hosts content
|
||||||
hosts_file = temp_dir / "hosts_no_ext"
|
hosts_file = temp_dir / "hosts_no_ext"
|
||||||
hosts_file.write_text('127.0.0.1 localhost')
|
hosts_file.write_text("127.0.0.1 localhost")
|
||||||
assert service.detect_file_format(hosts_file) == ImportFormat.HOSTS
|
assert service.detect_file_format(hosts_file) == ImportFormat.HOSTS
|
||||||
|
|
||||||
def test_detect_file_format_nonexistent(self, service):
|
def test_detect_file_format_nonexistent(self, service):
|
||||||
|
|
@ -415,13 +443,13 @@ class TestImportExportService:
|
||||||
valid_path = temp_dir / "export.json"
|
valid_path = temp_dir / "export.json"
|
||||||
warnings = service.validate_export_path(valid_path, ExportFormat.JSON)
|
warnings = service.validate_export_path(valid_path, ExportFormat.JSON)
|
||||||
assert len(warnings) == 0
|
assert len(warnings) == 0
|
||||||
|
|
||||||
# Existing file
|
# Existing file
|
||||||
existing_file = temp_dir / "existing.json"
|
existing_file = temp_dir / "existing.json"
|
||||||
existing_file.touch()
|
existing_file.touch()
|
||||||
warnings = service.validate_export_path(existing_file, ExportFormat.JSON)
|
warnings = service.validate_export_path(existing_file, ExportFormat.JSON)
|
||||||
assert any("already exists" in w for w in warnings)
|
assert any("already exists" in w for w in warnings)
|
||||||
|
|
||||||
# Wrong extension
|
# Wrong extension
|
||||||
wrong_ext = temp_dir / "file.txt"
|
wrong_ext = temp_dir / "file.txt"
|
||||||
warnings = service.validate_export_path(wrong_ext, ExportFormat.JSON)
|
warnings = service.validate_export_path(wrong_ext, ExportFormat.JSON)
|
||||||
|
|
@ -438,22 +466,22 @@ class TestImportExportService:
|
||||||
def test_export_import_roundtrip_json(self, service, sample_hosts_file, temp_dir):
|
def test_export_import_roundtrip_json(self, service, sample_hosts_file, temp_dir):
|
||||||
"""Test export-import roundtrip for JSON format."""
|
"""Test export-import roundtrip for JSON format."""
|
||||||
export_path = temp_dir / "roundtrip.json"
|
export_path = temp_dir / "roundtrip.json"
|
||||||
|
|
||||||
# Export
|
# Export
|
||||||
export_result = service.export_json_format(sample_hosts_file, export_path)
|
export_result = service.export_json_format(sample_hosts_file, export_path)
|
||||||
assert export_result.success is True
|
assert export_result.success is True
|
||||||
|
|
||||||
# Import
|
# Import
|
||||||
import_result = service.import_json_format(export_path)
|
import_result = service.import_json_format(export_path)
|
||||||
assert import_result.success is True
|
assert import_result.success is True
|
||||||
assert import_result.successfully_imported == len(sample_hosts_file.entries)
|
assert import_result.successfully_imported == len(sample_hosts_file.entries)
|
||||||
|
|
||||||
# Verify data integrity
|
# Verify data integrity
|
||||||
original_entries = sample_hosts_file.entries
|
original_entries = sample_hosts_file.entries
|
||||||
imported_entries = import_result.entries
|
imported_entries = import_result.entries
|
||||||
|
|
||||||
assert len(imported_entries) == len(original_entries)
|
assert len(imported_entries) == len(original_entries)
|
||||||
|
|
||||||
# Check specific entries
|
# Check specific entries
|
||||||
for orig, imported in zip(original_entries, imported_entries):
|
for orig, imported in zip(original_entries, imported_entries):
|
||||||
assert orig.ip_address == imported.ip_address
|
assert orig.ip_address == imported.ip_address
|
||||||
|
|
@ -465,11 +493,11 @@ class TestImportExportService:
|
||||||
def test_export_import_roundtrip_csv(self, service, sample_hosts_file, temp_dir):
|
def test_export_import_roundtrip_csv(self, service, sample_hosts_file, temp_dir):
|
||||||
"""Test export-import roundtrip for CSV format."""
|
"""Test export-import roundtrip for CSV format."""
|
||||||
export_path = temp_dir / "roundtrip.csv"
|
export_path = temp_dir / "roundtrip.csv"
|
||||||
|
|
||||||
# Export
|
# Export
|
||||||
export_result = service.export_csv_format(sample_hosts_file, export_path)
|
export_result = service.export_csv_format(sample_hosts_file, export_path)
|
||||||
assert export_result.success is True
|
assert export_result.success is True
|
||||||
|
|
||||||
# Import
|
# Import
|
||||||
import_result = service.import_csv_format(export_path)
|
import_result = service.import_csv_format(export_path)
|
||||||
assert import_result.success is True
|
assert import_result.success is True
|
||||||
|
|
@ -484,11 +512,11 @@ class TestImportExportService:
|
||||||
errors=["Error 1", "Error 2"],
|
errors=["Error 1", "Error 2"],
|
||||||
warnings=[],
|
warnings=[],
|
||||||
total_processed=5,
|
total_processed=5,
|
||||||
successfully_imported=0
|
successfully_imported=0,
|
||||||
)
|
)
|
||||||
assert result_with_errors.has_errors is True
|
assert result_with_errors.has_errors is True
|
||||||
assert result_with_errors.has_warnings is False
|
assert result_with_errors.has_warnings is False
|
||||||
|
|
||||||
# Result with warnings
|
# Result with warnings
|
||||||
result_with_warnings = ImportResult(
|
result_with_warnings = ImportResult(
|
||||||
success=True,
|
success=True,
|
||||||
|
|
@ -496,7 +524,7 @@ class TestImportExportService:
|
||||||
errors=[],
|
errors=[],
|
||||||
warnings=["Warning 1"],
|
warnings=["Warning 1"],
|
||||||
total_processed=5,
|
total_processed=5,
|
||||||
successfully_imported=5
|
successfully_imported=5,
|
||||||
)
|
)
|
||||||
assert result_with_warnings.has_errors is False
|
assert result_with_warnings.has_errors is False
|
||||||
assert result_with_warnings.has_warnings is True
|
assert result_with_warnings.has_warnings is True
|
||||||
|
|
@ -505,15 +533,15 @@ class TestImportExportService:
|
||||||
"""Test exporting empty hosts file."""
|
"""Test exporting empty hosts file."""
|
||||||
empty_hosts_file = HostsFile()
|
empty_hosts_file = HostsFile()
|
||||||
export_path = temp_dir / "empty.json"
|
export_path = temp_dir / "empty.json"
|
||||||
|
|
||||||
result = service.export_json_format(empty_hosts_file, export_path)
|
result = service.export_json_format(empty_hosts_file, export_path)
|
||||||
|
|
||||||
assert result.success is True
|
assert result.success is True
|
||||||
assert result.entries_exported == 0
|
assert result.entries_exported == 0
|
||||||
assert export_path.exists()
|
assert export_path.exists()
|
||||||
|
|
||||||
# Verify empty file structure
|
# Verify empty file structure
|
||||||
with open(export_path, 'r') as f:
|
with open(export_path, "r") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
assert data["metadata"]["total_entries"] == 0
|
assert data["metadata"]["total_entries"] == 0
|
||||||
assert len(data["entries"]) == 0
|
assert len(data["entries"]) == 0
|
||||||
|
|
@ -521,24 +549,24 @@ class TestImportExportService:
|
||||||
def test_large_hostnames_list_csv(self, service, temp_dir):
|
def test_large_hostnames_list_csv(self, service, temp_dir):
|
||||||
"""Test CSV export/import with large hostnames list."""
|
"""Test CSV export/import with large hostnames list."""
|
||||||
entry = HostEntry(
|
entry = HostEntry(
|
||||||
"192.168.1.1",
|
"192.168.1.1",
|
||||||
["host1.com", "host2.com", "host3.com", "host4.com", "host5.com"],
|
["host1.com", "host2.com", "host3.com", "host4.com", "host5.com"],
|
||||||
"Multiple hostnames",
|
"Multiple hostnames",
|
||||||
True
|
True,
|
||||||
)
|
)
|
||||||
hosts_file = HostsFile()
|
hosts_file = HostsFile()
|
||||||
hosts_file.entries = [entry]
|
hosts_file.entries = [entry]
|
||||||
|
|
||||||
export_path = temp_dir / "multi_hostnames.csv"
|
export_path = temp_dir / "multi_hostnames.csv"
|
||||||
|
|
||||||
# Export
|
# Export
|
||||||
export_result = service.export_csv_format(hosts_file, export_path)
|
export_result = service.export_csv_format(hosts_file, export_path)
|
||||||
assert export_result.success is True
|
assert export_result.success is True
|
||||||
|
|
||||||
# Import
|
# Import
|
||||||
import_result = service.import_csv_format(export_path)
|
import_result = service.import_csv_format(export_path)
|
||||||
assert import_result.success is True
|
assert import_result.success is True
|
||||||
|
|
||||||
imported_entry = import_result.entries[0]
|
imported_entry = import_result.entries[0]
|
||||||
assert len(imported_entry.hostnames) == 5
|
assert len(imported_entry.hostnames) == 5
|
||||||
assert "host1.com" in imported_entry.hostnames
|
assert "host1.com" in imported_entry.hostnames
|
||||||
|
|
|
||||||
|
|
@ -604,7 +604,7 @@ class TestHostsManagerApp:
|
||||||
mock_radio_set.id = "edit-entry-type-radio"
|
mock_radio_set.id = "edit-entry-type-radio"
|
||||||
mock_pressed_radio = Mock()
|
mock_pressed_radio = Mock()
|
||||||
mock_pressed_radio.id = "edit-ip-entry-radio"
|
mock_pressed_radio.id = "edit-ip-entry-radio"
|
||||||
|
|
||||||
event = Mock()
|
event = Mock()
|
||||||
event.radio_set = mock_radio_set
|
event.radio_set = mock_radio_set
|
||||||
event.pressed = mock_pressed_radio
|
event.pressed = mock_pressed_radio
|
||||||
|
|
@ -631,7 +631,7 @@ class TestHostsManagerApp:
|
||||||
mock_radio_set.id = "edit-entry-type-radio"
|
mock_radio_set.id = "edit-entry-type-radio"
|
||||||
mock_pressed_radio = Mock()
|
mock_pressed_radio = Mock()
|
||||||
mock_pressed_radio.id = "edit-dns-entry-radio"
|
mock_pressed_radio.id = "edit-dns-entry-radio"
|
||||||
|
|
||||||
event = Mock()
|
event = Mock()
|
||||||
event.radio_set = mock_radio_set
|
event.radio_set = mock_radio_set
|
||||||
event.pressed = mock_pressed_radio
|
event.pressed = mock_pressed_radio
|
||||||
|
|
@ -812,12 +812,12 @@ class TestHostsManagerApp:
|
||||||
mock_radio_set = Mock()
|
mock_radio_set = Mock()
|
||||||
mock_ip_radio = Mock()
|
mock_ip_radio = Mock()
|
||||||
mock_dns_radio = Mock()
|
mock_dns_radio = Mock()
|
||||||
|
|
||||||
# Use a simple object to track value assignment
|
# Use a simple object to track value assignment
|
||||||
class MockDNSInput:
|
class MockDNSInput:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.value = ""
|
self.value = ""
|
||||||
|
|
||||||
mock_dns_input = MockDNSInput()
|
mock_dns_input = MockDNSInput()
|
||||||
|
|
||||||
def mock_query_one(selector, widget_type=None):
|
def mock_query_one(selector, widget_type=None):
|
||||||
|
|
@ -833,14 +833,16 @@ class TestHostsManagerApp:
|
||||||
|
|
||||||
app.query_one = mock_query_one
|
app.query_one = mock_query_one
|
||||||
app.edit_handler.handle_entry_type_change = Mock()
|
app.edit_handler.handle_entry_type_change = Mock()
|
||||||
|
|
||||||
# Mock the set_timer method to avoid event loop issues in tests
|
# Mock the set_timer method to avoid event loop issues in tests
|
||||||
with patch.object(app, 'set_timer') as mock_set_timer:
|
with patch.object(app, "set_timer") as mock_set_timer:
|
||||||
app.edit_handler.populate_edit_form_with_type_detection()
|
app.edit_handler.populate_edit_form_with_type_detection()
|
||||||
|
|
||||||
# Verify timer was set with the correct callback
|
# Verify timer was set with the correct callback
|
||||||
mock_set_timer.assert_called_once_with(0.1, app.edit_handler._delayed_radio_setup)
|
mock_set_timer.assert_called_once_with(
|
||||||
|
0.1, app.edit_handler._delayed_radio_setup
|
||||||
|
)
|
||||||
|
|
||||||
# Manually call the delayed setup to test the actual logic
|
# Manually call the delayed setup to test the actual logic
|
||||||
app.edit_handler._delayed_radio_setup()
|
app.edit_handler._delayed_radio_setup()
|
||||||
|
|
||||||
|
|
@ -946,21 +948,24 @@ class TestHostsManagerApp:
|
||||||
app.manager.save_hosts_file = Mock(return_value=(True, "Success"))
|
app.manager.save_hosts_file = Mock(return_value=(True, "Success"))
|
||||||
app.table_handler.populate_entries_table = Mock()
|
app.table_handler.populate_entries_table = Mock()
|
||||||
app.details_handler.update_entry_details = Mock()
|
app.details_handler.update_entry_details = Mock()
|
||||||
|
|
||||||
# Create a mock that properly handles and closes coroutines
|
# Create a mock that properly handles and closes coroutines
|
||||||
def consume_coro(coro, **kwargs):
|
def consume_coro(coro, **kwargs):
|
||||||
# If it's a coroutine, close it to prevent warnings
|
# If it's a coroutine, close it to prevent warnings
|
||||||
if hasattr(coro, 'close'):
|
if hasattr(coro, "close"):
|
||||||
coro.close()
|
coro.close()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
app.run_worker = Mock(side_effect=consume_coro)
|
app.run_worker = Mock(side_effect=consume_coro)
|
||||||
|
|
||||||
# Test action_refresh_dns in edit mode - should proceed
|
# Test action_refresh_dns in edit mode - should proceed
|
||||||
app.action_refresh_dns()
|
app.action_refresh_dns()
|
||||||
# Should not show error message about read-only mode
|
# Should not show error message about read-only mode
|
||||||
error_calls = [call for call in app.update_status.call_args_list
|
error_calls = [
|
||||||
if "read-only mode" in str(call)]
|
call
|
||||||
|
for call in app.update_status.call_args_list
|
||||||
|
if "read-only mode" in str(call)
|
||||||
|
]
|
||||||
assert len(error_calls) == 0
|
assert len(error_calls) == 0
|
||||||
# Should start DNS resolution
|
# Should start DNS resolution
|
||||||
app.run_worker.assert_called()
|
app.run_worker.assert_called()
|
||||||
|
|
@ -972,8 +977,11 @@ class TestHostsManagerApp:
|
||||||
# Test action_update_single_dns in edit mode - should proceed
|
# Test action_update_single_dns in edit mode - should proceed
|
||||||
app.action_update_single_dns()
|
app.action_update_single_dns()
|
||||||
# Should not show error message about read-only mode
|
# Should not show error message about read-only mode
|
||||||
error_calls = [call for call in app.update_status.call_args_list
|
error_calls = [
|
||||||
if "read-only mode" in str(call)]
|
call
|
||||||
|
for call in app.update_status.call_args_list
|
||||||
|
if "read-only mode" in str(call)
|
||||||
|
]
|
||||||
assert len(error_calls) == 0
|
assert len(error_calls) == 0
|
||||||
# Should start DNS resolution
|
# Should start DNS resolution
|
||||||
app.run_worker.assert_called()
|
app.run_worker.assert_called()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue