initial upload
This commit is contained in:
parent
a68554c817
commit
7129798809
14
app/__init__.py
Normal file
14
app/__init__.py
Normal file
@ -0,0 +1,14 @@
|
||||
from flask import Flask
|
||||
import logging, os
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__, instance_path="/instance")
|
||||
|
||||
from app.views import main
|
||||
app.register_blueprint(main.bp)
|
||||
app.add_url_rule('/', endpoint='index')
|
||||
|
||||
return app
|
125
app/functions/utils.py
Normal file
125
app/functions/utils.py
Normal file
@ -0,0 +1,125 @@
|
||||
import yaml
|
||||
import os
|
||||
import logging
|
||||
from functools import wraps
|
||||
from netmiko import ConnectHandler, NetmikoTimeoutException, NetmikoAuthenticationException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Exception handler
|
||||
def exception_handler(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
logging.exception(f"Exception occurred in {func.__name__}")
|
||||
return "An error occurred", 500
|
||||
return wrapper
|
||||
|
||||
@exception_handler
|
||||
def load_yaml(filename):
|
||||
if not filename.endswith('.yaml'):
|
||||
filename = f"{filename}.yaml"
|
||||
|
||||
config_path = os.path.join("/instance", filename)
|
||||
|
||||
if not os.path.exists(config_path):
|
||||
logger.error(f"Configuration file not found: {config_path}")
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(config_path, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
if data is None:
|
||||
logger.warning(f"Empty configuration file: {filename}")
|
||||
return {}
|
||||
return data
|
||||
except yaml.YAMLError as e:
|
||||
logger.error(f"YAML parsing error in {filename}: {e}")
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error loading {filename}: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def establish_connection(device_config):
|
||||
try:
|
||||
return ConnectHandler(**device_config)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to establish connection to {device_config['host']}: {e}")
|
||||
raise
|
||||
|
||||
def execute_command(device, command_config, target):
|
||||
device_config = {
|
||||
'device_type': device['type'],
|
||||
'host': device['host'],
|
||||
'username': device['username'],
|
||||
'password': device['password'],
|
||||
'port': device.get('port', 22),
|
||||
'timeout': device.get('timeout', 10),
|
||||
'session_timeout': device.get('session_timeout', 60),
|
||||
'conn_timeout': device.get('conn_timeout', 10),
|
||||
'auth_timeout': device.get('auth_timeout', 10),
|
||||
}
|
||||
|
||||
command_timeout = device.get('command_timeout', 30)
|
||||
|
||||
try:
|
||||
with establish_connection(device_config) as connection:
|
||||
# Format the command
|
||||
command = str(command_config.get('format').format(target=target).strip())
|
||||
|
||||
# Execute the command
|
||||
output = connection.send_command(
|
||||
command,
|
||||
read_timeout=command_timeout,
|
||||
strip_command=True,
|
||||
strip_prompt=True,
|
||||
max_loops=int(command_timeout * 10)
|
||||
)
|
||||
|
||||
# Clean output
|
||||
output = output.strip() if output else ""
|
||||
logger.debug(f"Command output: {output}")
|
||||
|
||||
if not output:
|
||||
return {
|
||||
'raw_output': "No output received from device",
|
||||
'structured_data': None,
|
||||
'error': True,
|
||||
'error_type': 'no_output'
|
||||
}
|
||||
|
||||
return {
|
||||
'raw_output': output,
|
||||
'error': False
|
||||
}
|
||||
|
||||
except NetmikoTimeoutException as e:
|
||||
error_msg = f"Timeout error on {device['host']}: {e}"
|
||||
logger.error(error_msg)
|
||||
return {
|
||||
'raw_output': error_msg,
|
||||
'structured_data': None,
|
||||
'error': True,
|
||||
'error_type': 'timeout'
|
||||
}
|
||||
except NetmikoAuthenticationException as e:
|
||||
error_msg = f"Authentication failed for {device['host']}: {e}"
|
||||
logger.error(error_msg)
|
||||
return {
|
||||
'raw_output': error_msg,
|
||||
'structured_data': None,
|
||||
'error': True,
|
||||
'error_type': 'auth'
|
||||
}
|
||||
except Exception as e:
|
||||
error_msg = f"An unexpected error occurred on {device['host']}: {e}"
|
||||
logger.error(error_msg)
|
||||
return {
|
||||
'raw_output': error_msg,
|
||||
'structured_data': None,
|
||||
'error': True,
|
||||
'error_type': 'connection'
|
||||
}
|
5
app/requirements.txt
Normal file
5
app/requirements.txt
Normal file
@ -0,0 +1,5 @@
|
||||
flask==3.1.0
|
||||
pyyaml==6.0.2
|
||||
netmiko==4.5.0
|
||||
werkzeug==3.1.3
|
||||
gunicorn==23.0.0
|
3
app/static/css/input.css
Normal file
3
app/static/css/input.css
Normal file
@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
15
app/static/images/default-favicon.svg
Normal file
15
app/static/images/default-favicon.svg
Normal file
@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<!-- Background -->
|
||||
<rect width="32" height="32" fill="#1e40af" rx="6"/>
|
||||
<!-- Network nodes -->
|
||||
<circle cx="16" cy="16" r="4" fill="white"/>
|
||||
<circle cx="8" cy="8" r="3" fill="white"/>
|
||||
<circle cx="24" cy="8" r="3" fill="white"/>
|
||||
<circle cx="8" cy="24" r="3" fill="white"/>
|
||||
<circle cx="24" cy="24" r="3" fill="white"/>
|
||||
<!-- Connection lines -->
|
||||
<line x1="10" y1="10" x2="14" y2="14" stroke="white" stroke-width="1.5"/>
|
||||
<line x1="22" y1="10" x2="18" y2="14" stroke="white" stroke-width="1.5"/>
|
||||
<line x1="10" y1="22" x2="14" y2="18" stroke="white" stroke-width="1.5"/>
|
||||
<line x1="22" y1="22" x2="18" y2="18" stroke="white" stroke-width="1.5"/>
|
||||
</svg>
|
After Width: | Height: | Size: 755 B |
5
app/static/images/default-logo-dark.svg
Normal file
5
app/static/images/default-logo-dark.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="50" viewBox="0 0 200 50">
|
||||
<text x="10" y="35" font-family="Arial" font-size="30" fill="#FFFFFF">
|
||||
photonglass
|
||||
</text>
|
||||
</svg>
|
After Width: | Height: | Size: 192 B |
5
app/static/images/default-logo-light.svg
Normal file
5
app/static/images/default-logo-light.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="50" viewBox="0 0 200 50">
|
||||
<text x="10" y="35" font-family="Arial" font-size="30" fill="#1F2937">
|
||||
photonglass
|
||||
</text>
|
||||
</svg>
|
After Width: | Height: | Size: 192 B |
168
app/static/js/main.js
Normal file
168
app/static/js/main.js
Normal file
@ -0,0 +1,168 @@
|
||||
const app = Vue.createApp({
|
||||
delimiters: ['${', '}'],
|
||||
data() {
|
||||
return {
|
||||
selectedDevice: '',
|
||||
selectedCommand: '',
|
||||
targetIp: '',
|
||||
isLoading: false,
|
||||
isLoadingIp: false,
|
||||
commandResult: '',
|
||||
devices: window.initialData?.devices || [],
|
||||
commands: window.initialData?.commands || [],
|
||||
currentCommand: null,
|
||||
showHelp: false,
|
||||
showTerms: false,
|
||||
isOpen: false,
|
||||
isDark: localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches),
|
||||
site_config: window.initialData?.site_config || {},
|
||||
peeringdbUrl: window.initialData?.site_config?.footer?.external_links?.peeringdb || '',
|
||||
githubUrl: window.initialData?.site_config?.footer?.external_links?.github || ''
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.updateThemeClass();
|
||||
// Make sure commands are loaded
|
||||
if (window.initialData?.commands) {
|
||||
console.log('Commands loaded:', this.commands);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
selectedCommand: {
|
||||
handler(newVal) {
|
||||
this.currentCommand = this.commands.find(cmd => cmd.id === newVal);
|
||||
console.log("Current command updated:", this.currentCommand);
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isValidInput() {
|
||||
if (!this.currentCommand || !this.targetIp) return false;
|
||||
if (this.currentCommand.field?.validation) {
|
||||
const pattern = new RegExp(this.currentCommand.field.validation);
|
||||
return pattern.test(this.targetIp);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async getMyIp() {
|
||||
if (this.isLoadingIp) return;
|
||||
this.isLoadingIp = true;
|
||||
try {
|
||||
const response = await fetch('/my-ip');
|
||||
const data = await response.json();
|
||||
if (response.ok && data.ip) {
|
||||
this.targetIp = data.ip;
|
||||
} else {
|
||||
console.error('Failed to get IP address');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching IP:', error);
|
||||
} finally {
|
||||
this.isLoadingIp = false;
|
||||
}
|
||||
},
|
||||
toggleTheme() {
|
||||
this.isDark = !this.isDark;
|
||||
localStorage.theme = this.isDark ? 'dark' : 'light';
|
||||
this.updateThemeClass();
|
||||
},
|
||||
updateThemeClass() {
|
||||
if (this.isDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
},
|
||||
toggleDevice(device) {
|
||||
// Update the selected device state
|
||||
const wasDeselected = this.selectedDevice === device;
|
||||
this.selectedDevice = wasDeselected ? '' : device;
|
||||
|
||||
// Only reset states if we're deselecting the device entirely
|
||||
if (wasDeselected) {
|
||||
this.selectedCommand = '';
|
||||
this.targetIp = '';
|
||||
this.commandResult = '';
|
||||
}
|
||||
|
||||
// Force a re-render to ensure styling is updated
|
||||
this.$nextTick(() => {
|
||||
this.$forceUpdate();
|
||||
});
|
||||
},
|
||||
async executeCommand() {
|
||||
if (!this.isValidInput) {
|
||||
this.commandResult = '❌ Error: Please enter a valid input according to the command requirements';
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
this.commandResult = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/execute', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
device: this.selectedDevice,
|
||||
command: this.selectedCommand,
|
||||
target: this.targetIp
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
this.commandResult = `❌ Error: ${data.message || 'An unknown error occurred'}`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
// Handle error responses with specific error types
|
||||
const errorPrefix = data.error_type === 'timeout' ? '🕒 Timeout Error: ' :
|
||||
data.error_type === 'auth' ? '🔒 Authentication Error: ' :
|
||||
data.error_type === 'connection' ? '🔌 Connection Error: ' :
|
||||
data.error_type === 'no_output' ? '📭 No Output Error: ' :
|
||||
'❌ Error: ';
|
||||
this.commandResult = errorPrefix + data.message;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle successful response
|
||||
if (data.result) {
|
||||
this.commandResult = data.result;
|
||||
} else {
|
||||
this.commandResult = '❌ Error: No output received from command';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Command execution error:', error);
|
||||
this.commandResult = '❌ Error: Failed to execute command. Please try again.';
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
toggleDropdown(event) {
|
||||
this.isOpen = !this.isOpen;
|
||||
},
|
||||
closeDropdown(event) {
|
||||
// Only close if clicking outside the dropdown
|
||||
if (!event.target.closest('.relative')) {
|
||||
this.isOpen = false;
|
||||
}
|
||||
},
|
||||
selectCommand(command) {
|
||||
this.selectedCommand = command;
|
||||
// Add a small delay before closing to ensure the selection is visible
|
||||
setTimeout(() => {
|
||||
this.isOpen = false;
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.mount('#app');
|
35
app/tailwind.config.js
Normal file
35
app/tailwind.config.js
Normal file
@ -0,0 +1,35 @@
|
||||
module.exports = {
|
||||
content: [
|
||||
"./templates/**/*.html",
|
||||
"./static/**/*.js"
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#4a5568'
|
||||
},
|
||||
dark: {
|
||||
DEFAULT: '#121212',
|
||||
100: '#1E1E1E',
|
||||
200: '#2D2D2D',
|
||||
300: '#3D3D3D',
|
||||
400: '#4D4D4D',
|
||||
500: '#5C5C5C'
|
||||
}
|
||||
},
|
||||
fontSize: {
|
||||
'base': '1.125rem',
|
||||
'sm': '1rem',
|
||||
'lg': '1.25rem',
|
||||
'xl': '1.375rem',
|
||||
'2xl': '1.5rem',
|
||||
'3xl': '1.875rem',
|
||||
'4xl': '2.25rem',
|
||||
'5xl': '3rem',
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
50
app/templates/base.html
Normal file
50
app/templates/base.html
Normal file
@ -0,0 +1,50 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<link rel="icon" type="image/svg+xml" href="{{ config.site.favicon }}"/>
|
||||
<link rel="apple-touch-icon" href="{{ config.site.favicon }}"/>
|
||||
<title>{{ config.title }}</title>
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||
<link href="{{ url_for('static', filename='css/styles.css') }}" rel="stylesheet"/>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@6.11.0/css/flag-icons.min.css"/>
|
||||
<script>
|
||||
// Initialize window.initialData for Vue
|
||||
window.initialData = {
|
||||
config: {{ config|tojson|safe }},
|
||||
commands: {{ commands|tojson|safe }},
|
||||
devices: {{ devices|tojson|safe }}
|
||||
};
|
||||
|
||||
// Check for saved theme preference or default to 'light'
|
||||
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body class="min-h-screen flex flex-col bg-white dark:bg-gray-900">
|
||||
<div id="app" class="min-h-screen flex flex-col">
|
||||
<div class="flex-grow w-full max-w-7xl px-4 mx-auto">
|
||||
<div class="mt-8 mb-12 flex justify-center items-center">
|
||||
<a href="{{ config.logo.href }}" target="_blank" class="inline-block">
|
||||
<img src="{{ config.logo.light }}" alt="Logo" class="h-12 w-auto dark:hidden"/>
|
||||
<img src="{{ config.logo.dark }}" alt="Logo" class="h-12 w-auto hidden dark:block"/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{% include 'footer.html' %}
|
||||
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
194
app/templates/footer.html
Normal file
194
app/templates/footer.html
Normal file
@ -0,0 +1,194 @@
|
||||
{% block footer %}
|
||||
<footer class="w-full bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800 mt-auto">
|
||||
<div class="container mx-auto py-8 px-4">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center space-y-6 md:space-y-0">
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ config.footer.text }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Links -->
|
||||
<div class="flex items-center space-x-8">
|
||||
<button @click="showTerms = true" class="text-sm font-medium text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors duration-200">
|
||||
Terms
|
||||
</button>
|
||||
|
||||
<!-- Help Button -->
|
||||
<button @click.prevent="showHelp = !showHelp" class="text-sm font-medium text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors duration-200 cursor-pointer">
|
||||
Help
|
||||
</button>
|
||||
|
||||
<!-- PeeringDB Link -->
|
||||
<a href="{{ config.footer.external_links.peeringdb }}" target="_blank" rel="noopener noreferrer" class="text-sm font-medium text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors duration-200">
|
||||
PeeringDB
|
||||
</a>
|
||||
|
||||
<!-- GitHub Link -->
|
||||
<a href="{{ config.footer.external_links.github }}" target="_blank" rel="noopener noreferrer" class="text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors duration-200 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<button
|
||||
@click="toggleTheme"
|
||||
class="text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors duration-200 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 hidden dark:block"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 block dark:hidden"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Terms Modal -->
|
||||
<transition
|
||||
enter-active-class="transition ease-in-out duration-150"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition ease-in-out duration-150"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0">
|
||||
<div
|
||||
v-if="showTerms"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 rounded-lg max-w-lg w-full p-6 transform transition-all duration-150"
|
||||
:class="showTerms ? 'scale-100 opacity-100' : 'scale-95 opacity-0'">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
Terms of Service
|
||||
</h3>
|
||||
<button @click="showTerms = false" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="prose dark:prose-invert max-w-none">
|
||||
<p>
|
||||
By using this Looking Glass service, you agree
|
||||
to:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
Use the service responsibly and not for any
|
||||
malicious purposes
|
||||
</li>
|
||||
<li>
|
||||
Not attempt to overload or disrupt the
|
||||
service
|
||||
</li>
|
||||
<li>
|
||||
Respect the privacy and security measures in
|
||||
place
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Help Modal -->
|
||||
<transition
|
||||
enter-active-class="transition ease-in-out duration-150"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition ease-in-out duration-150"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showHelp"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
|
||||
>
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 rounded-lg max-w-lg w-full max-h-[80vh] flex flex-col transform transition-all duration-150"
|
||||
:class="showHelp ? 'scale-100 opacity-100' : 'scale-95 opacity-0'"
|
||||
@click.stop
|
||||
>
|
||||
<!-- Fixed Header -->
|
||||
<div
|
||||
class="flex-none flex justify-between items-center p-6 border-b border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<h3
|
||||
class="text-lg font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Available Commands
|
||||
</h3>
|
||||
<button
|
||||
@click="showHelp = false"
|
||||
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Scrollable Content -->
|
||||
<div class="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
The following network diagnostic commands are
|
||||
available:
|
||||
</p>
|
||||
<div
|
||||
v-for="command in commands"
|
||||
:key="command.id"
|
||||
class="border-b border-gray-200 dark:border-gray-700 last:border-0 pb-4 last:pb-0"
|
||||
>
|
||||
<h4
|
||||
class="font-medium text-gray-900 dark:text-white capitalize mb-2 flex items-center"
|
||||
>
|
||||
<span
|
||||
class="bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 px-3 py-1.5 rounded-lg text-sm mr-3 font-mono hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors duration-200"
|
||||
v-text="command.display_name"
|
||||
></span>
|
||||
</h4>
|
||||
<div
|
||||
class="pl-2 border-l-2 border-gray-400 dark:border-gray-500"
|
||||
>
|
||||
<p
|
||||
class="text-gray-600 dark:text-gray-400 text-sm leading-relaxed whitespace-pre-line"
|
||||
v-text="command.description || 'No description available'"
|
||||
></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
{% endblock %}
|
162
app/templates/index.html
Normal file
162
app/templates/index.html
Normal file
@ -0,0 +1,162 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="w-full px-4">
|
||||
<!-- Device Selection -->
|
||||
<div class="mb-12 max-w-6xl mx-auto">
|
||||
<div class="mb-6"></div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-6 gap-4">
|
||||
{% for device in devices %}
|
||||
<button @click="toggleDevice('{{ device.id }}')"
|
||||
:class="[
|
||||
'flex flex-row items-center justify-between px-4 py-3 h-24 rounded-lg border-2 transition-all duration-200 ease-in-out transform hover:shadow-md',
|
||||
selectedDevice === '{{ device.id }}'
|
||||
? 'bg-gray-100 dark:bg-gray-800 border-gray-700 dark:border-gray-300 text-gray-700 dark:text-gray-300 shadow-lg scale-[1.02]'
|
||||
: 'bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-800 hover:border-gray-500 dark:hover:border-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
]">
|
||||
<div class="flex-grow">
|
||||
<div class="text-base font-medium text-gray-800 dark:text-gray-200 mb-1">{{ device.display_name }}</div>
|
||||
{% if device.subtext %}
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">{{ device.subtext }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if device.country_code %}
|
||||
<span class="fi fi-{{ device.country_code | lower }} flex-shrink-0 border border-gray-200 dark:border-gray-900"></span>
|
||||
{% endif %}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Query Type and Target Selection -->
|
||||
<transition
|
||||
enter-active-class="transition-all ease-in-out duration-200"
|
||||
enter-from-class="opacity-0 translate-y-4"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition-all ease-in-out duration-200"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0">
|
||||
<div v-if="selectedDevice" class="mb-8 max-w-4xl mx-auto">
|
||||
<div class="mb-6"></div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
|
||||
<!-- Query Type Dropdown -->
|
||||
<div :class="['relative', selectedCommand ? 'md:col-span-5' : 'md:col-span-12']" @click.outside="closeDropdown">
|
||||
<button type="button"
|
||||
@click.stop="toggleDropdown"
|
||||
class="relative w-full h-12 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-lg shadow-sm pl-3 pr-10 text-left cursor-pointer focus:outline-none focus:ring-1 focus:ring-gray-500 focus:border-gray-500 sm:text-sm transition-all duration-200 ease-in-out hover:border-gray-400 dark:hover:border-gray-600">
|
||||
<span class="block truncate text-gray-700 dark:text-gray-300">
|
||||
${selectedCommand ? commands.find(c => c.id === selectedCommand).display_name : 'Select query type...'}
|
||||
</span>
|
||||
<span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none text-gray-400">
|
||||
<svg class="h-5 w-5 transition-transform duration-200"
|
||||
:class="{'rotate-180': isOpen}"
|
||||
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<transition
|
||||
enter-active-class="transition-all ease-in-out duration-200"
|
||||
enter-from-class="transform opacity-0 scale-95 -translate-y-2"
|
||||
enter-to-class="transform opacity-100 scale-100 translate-y-0"
|
||||
leave-active-class="transition-all ease-in-out duration-200"
|
||||
leave-from-class="transform opacity-100 scale-100 translate-y-0"
|
||||
leave-to-class="transform opacity-0 scale-95 -translate-y-2">
|
||||
<div v-if="isOpen"
|
||||
class="absolute z-10 mt-1 w-full bg-white dark:bg-gray-800 shadow-lg max-h-60 rounded-lg py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||
{% for command in commands %}
|
||||
<div @click.stop.prevent="selectCommand('{{ command.id }}')"
|
||||
:class="['cursor-pointer select-none relative py-2 pl-3 pr-9 transition-all duration-200 ease-in-out hover:bg-gray-200 dark:hover:bg-gray-600 hover:text-gray-900 dark:hover:text-white',
|
||||
selectedCommand === '{{ command.id }}' ? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-white' : 'text-gray-700 dark:text-gray-300']">
|
||||
<div class="flex items-center">
|
||||
<span class="block truncate font-medium">{{ command.display_name }}</span>
|
||||
</div>
|
||||
<span v-if="selectedCommand === '{{ command.id }}'"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-4 text-gray-600 dark:text-gray-400">
|
||||
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- Target Input with Execute Button -->
|
||||
<transition
|
||||
enter-active-class="transition-all ease-in-out duration-200"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition-all ease-in-out duration-200"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95">
|
||||
<div v-if="selectedCommand && currentCommand"
|
||||
class="md:col-span-7 relative flex items-center gap-2 transform transition-all duration-200 ease-in-out">
|
||||
|
||||
<!-- Text Input Field -->
|
||||
<div class="flex-1 relative">
|
||||
<input
|
||||
v-model="targetIp"
|
||||
type="text"
|
||||
@keyup.enter="isValidInput && executeCommand()"
|
||||
class="w-full h-12 rounded-lg px-3 pr-20 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300 focus:border-gray-500 dark:focus:border-gray-500 transition-all duration-200 ease-in-out"
|
||||
:class="{'pr-32': targetIp}"
|
||||
:placeholder="currentCommand?.field?.placeholder || 'Enter target'"
|
||||
:pattern="currentCommand?.field?.validation || '.*'"
|
||||
required>
|
||||
<button
|
||||
type="button"
|
||||
@click="getMyIp"
|
||||
:disabled="isLoadingIp"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-all duration-200 ease-in-out disabled:opacity-50"
|
||||
:class="{'opacity-0 translate-x-2': targetIp}">
|
||||
<span v-if="isLoadingIp">...</span>
|
||||
<span v-else>My IP</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Execute Button -->
|
||||
<transition
|
||||
enter-active-class="transition-all ease-in-out duration-200"
|
||||
enter-from-class="opacity-0 scale-90 -translate-x-2"
|
||||
enter-to-class="opacity-100 scale-100 translate-x-0"
|
||||
leave-active-class="transition-all ease-in-out duration-200"
|
||||
leave-from-class="opacity-100 scale-100 translate-x-0"
|
||||
leave-to-class="opacity-0 scale-90 translate-x-2">
|
||||
<button v-if="targetIp"
|
||||
@click="executeCommand"
|
||||
:disabled="isLoading"
|
||||
class="h-12 px-4 rounded-lg border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:border-gray-400 dark:hover:border-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center transform transition-all duration-200 ease-in-out">
|
||||
<transition
|
||||
enter-active-class="transition-all ease-in-out duration-200"
|
||||
enter-from-class="opacity-0 rotate-180 scale-0"
|
||||
enter-to-class="opacity-100 rotate-0 scale-100"
|
||||
leave-active-class="transition-all ease-in-out duration-200"
|
||||
leave-from-class="opacity-100 rotate-0 scale-100"
|
||||
leave-to-class="opacity-0 rotate-180 scale-0">
|
||||
<!-- Loading Spinner -->
|
||||
<svg v-if="isLoading" class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<!-- Search Icon -->
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</transition>
|
||||
</button>
|
||||
</transition>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Results -->
|
||||
<div v-if="commandResult" class="mb-8 max-w-6xl mx-auto">
|
||||
<h2 class="text-gray-400 text-sm mb-4">Results</h2>
|
||||
<pre class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded p-4 text-gray-800 dark:text-gray-300 font-mono text-sm overflow-x-auto whitespace-pre break-words">${commandResult}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
114
app/views/main.py
Normal file
114
app/views/main.py
Normal file
@ -0,0 +1,114 @@
|
||||
from flask import (
|
||||
Blueprint, request, jsonify, render_template
|
||||
)
|
||||
import logging
|
||||
|
||||
from app.functions.utils import load_yaml, execute_command, exception_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint('main', __name__)
|
||||
|
||||
@bp.route('/')
|
||||
@exception_handler
|
||||
def index():
|
||||
config = load_yaml('config')
|
||||
devices = load_yaml('devices')
|
||||
commands = load_yaml('commands')
|
||||
|
||||
for device in devices:
|
||||
device.pop('username', None)
|
||||
device.pop('password', None)
|
||||
device.pop('host', None)
|
||||
device.pop('port', None)
|
||||
|
||||
return render_template('index.html', config=config, devices=devices, commands=commands)
|
||||
|
||||
|
||||
@bp.route('/my-ip', methods=['GET'])
|
||||
@exception_handler
|
||||
def get_my_ip():
|
||||
# Check headers in order of reliability
|
||||
client_ip = None
|
||||
|
||||
forwarded_for = request.headers.get('X-Forwarded-For')
|
||||
if forwarded_for:
|
||||
client_ip = forwarded_for.split(',')[0].strip()
|
||||
|
||||
if not client_ip:
|
||||
real_ip = request.headers.get('X-Real-IP')
|
||||
if real_ip:
|
||||
client_ip = real_ip
|
||||
|
||||
if not client_ip:
|
||||
client_ip = request.remote_addr
|
||||
|
||||
return jsonify({'ip': client_ip})
|
||||
|
||||
|
||||
@bp.route('/execute', methods=['POST'])
|
||||
@exception_handler
|
||||
def execute():
|
||||
data = request.get_json()
|
||||
device = data.get('device')
|
||||
command = data.get('command')
|
||||
target = data.get('target')
|
||||
|
||||
if not all([device, command, target]):
|
||||
raise Exception("Missing required parameters")
|
||||
|
||||
# Load configurations
|
||||
devices = load_yaml('devices')
|
||||
commands = load_yaml('commands')
|
||||
|
||||
# Find device and command configurations
|
||||
device = next((d for d in devices if d['id'] == device), None)
|
||||
command = next((c for c in commands if c['id'] == command), None)
|
||||
|
||||
if not device or not command:
|
||||
raise Exception("Device or command not found")
|
||||
|
||||
# Verify command is allowed for this device
|
||||
if command['id'] not in device.get('commands', []):
|
||||
raise Exception("Command not allowed for this device")
|
||||
|
||||
try:
|
||||
# Execute the command using network_utils
|
||||
result = execute_command(device, command, target)
|
||||
|
||||
if not result:
|
||||
error_msg = 'No response from command execution'
|
||||
logger.error(error_msg)
|
||||
return jsonify({
|
||||
'error': True,
|
||||
'message': error_msg,
|
||||
'error_type': 'no_response'
|
||||
})
|
||||
|
||||
# Check for error state
|
||||
if result.get('error', False):
|
||||
error_msg = result['raw_output']
|
||||
logger.error(f"Command execution failed: {error_msg}")
|
||||
return jsonify({
|
||||
'error': True,
|
||||
'message': error_msg,
|
||||
'error_type': result.get('error_type', 'general')
|
||||
})
|
||||
|
||||
# Log successful execution
|
||||
logger.info(f"Successfully executed command {command} on {device}")
|
||||
|
||||
# Return successful result
|
||||
return jsonify({
|
||||
'error': False,
|
||||
'result': result['raw_output'],
|
||||
'structured_data': result.get('structured_data')
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during command execution: {str(e)}")
|
||||
return jsonify({
|
||||
'error': True,
|
||||
'message': f"An unexpected error occurred: {str(e)}",
|
||||
'error_type': 'general'
|
||||
})
|
Loading…
x
Reference in New Issue
Block a user