initial upload

This commit is contained in:
Micky 2024-12-22 21:33:36 +11:00
parent a68554c817
commit 7129798809
13 changed files with 895 additions and 0 deletions

14
app/__init__.py Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View 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

View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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'
})