Rôles, tableau de bord et tests unitaires
This commit is contained in:
parent
f508a2fcb5
commit
1799035236
51 changed files with 4350 additions and 0 deletions
128
templates/server/edit.html.twig
Normal file
128
templates/server/edit.html.twig
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Edit {{ server.name }} - ServerControl{% endblock %}
|
||||
|
||||
{% block stylesheets %}
|
||||
<style>
|
||||
.form-section-title {
|
||||
font-size: .7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
color: #6c757d;
|
||||
margin-bottom: .75rem;
|
||||
padding-bottom: .4rem;
|
||||
border-bottom: 1px solid rgba(108,117,125,.2);
|
||||
}
|
||||
.field-icon { color: #6c757d; }
|
||||
.input-group-text { background: transparent; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-7 col-lg-6">
|
||||
<a href="{{ path('app_server_show', {id: server.id}) }}" class="text-muted text-decoration-none small">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to {{ server.name }}
|
||||
</a>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header py-3 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-pencil-square fs-5 text-warning"></i>
|
||||
<div>
|
||||
<h5 class="mb-0">Edit server</h5>
|
||||
<small class="text-muted">{{ server.name }} — {{ server.ipAddress }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
{{ form_start(form) }}
|
||||
|
||||
<p class="form-section-title"><i class="bi bi-info-circle me-1"></i>General</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Server name</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-tag"></i></span>
|
||||
{{ form_widget(form.name, {attr: {class: 'form-control'}}) }}
|
||||
</div>
|
||||
{{ form_errors(form.name) }}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">Description <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-card-text"></i></span>
|
||||
{{ form_widget(form.description, {attr: {class: 'form-control', rows: 2}}) }}
|
||||
</div>
|
||||
{{ form_errors(form.description) }}
|
||||
</div>
|
||||
|
||||
<p class="form-section-title"><i class="bi bi-terminal me-1"></i>SSH Connection</p>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-8">
|
||||
<label class="form-label fw-semibold">IP Address</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-router"></i></span>
|
||||
{{ form_widget(form.ipAddress, {attr: {class: 'form-control'}}) }}
|
||||
</div>
|
||||
{{ form_errors(form.ipAddress) }}
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label fw-semibold">SSH Port</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-plug"></i></span>
|
||||
{{ form_widget(form.sshPort, {attr: {class: 'form-control'}}) }}
|
||||
</div>
|
||||
{{ form_errors(form.sshPort) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">SSH User</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-person-badge"></i></span>
|
||||
{{ form_widget(form.sshUser, {attr: {class: 'form-control'}}) }}
|
||||
</div>
|
||||
{{ form_errors(form.sshUser) }}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">SSH Password</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-key"></i></span>
|
||||
{{ form_widget(form.sshPassword, {attr: {class: 'form-control', placeholder: 'Leave blank to keep current password', id: 'ssh-password-input'}}) }}
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePwd()">
|
||||
<i class="bi bi-eye" id="pwd-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text"><i class="bi bi-info-circle me-1"></i>Leave blank to keep the current password.</div>
|
||||
{{ form_errors(form.sshPassword) }}
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 pt-2 border-top">
|
||||
<button type="submit" class="btn btn-warning px-4">
|
||||
<i class="bi bi-floppy me-1"></i>Save changes
|
||||
</button>
|
||||
<a href="{{ path('app_server_show', {id: server.id}) }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
<script>
|
||||
function togglePwd() {
|
||||
const input = document.getElementById('ssh-password-input');
|
||||
const eye = document.getElementById('pwd-eye');
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
eye.className = 'bi bi-eye-slash';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
eye.className = 'bi bi-eye';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
131
templates/server/index.html.twig
Normal file
131
templates/server/index.html.twig
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Servers - ServerControl{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3">Servers</h1>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<a href="{{ path('app_server_new') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-lg me-1"></i>Add Server
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="get" class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control" name="search" placeholder="Search by name or IP..."
|
||||
value="{{ search ?? '' }}">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<select class="form-select" name="status">
|
||||
<option value="">All statuses</option>
|
||||
<option value="online" {% if status == 'online' %}selected{% endif %}>Online</option>
|
||||
<option value="offline" {% if status == 'offline' %}selected{% endif %}>Offline</option>
|
||||
<option value="unknown" {% if status == 'unknown' %}selected{% endif %}>Unknown</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-outline-secondary w-100">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if servers|length == 0 %}
|
||||
<div class="alert alert-info">Aucun serveur trouver.</div>
|
||||
{% else %}
|
||||
<div class="table-responsive card">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>IP Address</th>
|
||||
<th>SSH Port</th>
|
||||
<th>Status</th>
|
||||
<th>Owner</th>
|
||||
<th>Added</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for server in servers %}
|
||||
<tr>
|
||||
<td><strong>{{ server.name }}</strong></td>
|
||||
<td><code>{{ server.ipAddress }}</code></td>
|
||||
<td>{{ server.sshPort }}</td>
|
||||
<td>
|
||||
<span class="status-{{ server.lastStatus }}" id="status-{{ server.id }}">
|
||||
<i class="bi bi-circle-fill me-1" style="font-size:.5rem;"></i>
|
||||
{{ server.lastStatus|capitalize }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ server.owner.pseudo }}</td>
|
||||
<td>{{ server.createdAt|date('d/m/Y') }}</td>
|
||||
<td>
|
||||
<a href="{{ path('app_server_show', {id: server.id}) }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<a href="{{ path('app_server_edit', {id: server.id}) }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<button class="btn btn-sm btn-outline-danger" data-bs-toggle="modal"
|
||||
data-bs-target="#deleteModal{{ server.id }}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 d-flex justify-content-center">
|
||||
{{ knp_pagination_render(servers) }}
|
||||
</div>
|
||||
|
||||
{% for server in servers %}
|
||||
<div class="modal fade" id="deleteModal{{ server.id }}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete Server</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
Êtes-vous sûre de vouloir suprimmer <strong>{{ server.name }}</strong> ?
|
||||
Tout les services et journaux associé seront supprimés.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<form method="post" action="{{ path('app_server_delete', {id: server.id}) }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete_server_' ~ server.id) }}">
|
||||
<button class="btn btn-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
<script>
|
||||
function refreshStatuses() {
|
||||
document.querySelectorAll('[id^="status-"]').forEach(el => {
|
||||
const id = el.id.replace('status-', '');
|
||||
fetch(`/server/${id}/status`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
el.className = `status-${data.status}`;
|
||||
el.innerHTML = `<i class="bi bi-circle-fill me-1" style="font-size:.5rem;"></i>${data.status.charAt(0).toUpperCase() + data.status.slice(1)}`;
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
setInterval(refreshStatuses, 30000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
110
templates/server/new.html.twig
Normal file
110
templates/server/new.html.twig
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Add Server - ServerControl{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-7 col-lg-6">
|
||||
<a href="{{ path('app_server_index') }}" class="text-muted text-decoration-none small">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to servers
|
||||
</a>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header py-3 d-flex align-items-center gap-2">
|
||||
<i class="bi bi-hdd-stack-fill fs-5 text-primary"></i>
|
||||
<div>
|
||||
<h5 class="mb-0">Ajouter un nouveaux serveur</h5>
|
||||
<small class="text-muted">Renseignez les détails de connection SSH</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
{{ form_start(form) }}
|
||||
|
||||
<p class="form-section-title"><i class="bi bi-info-circle me-1"></i>General</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Server name</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-tag"></i></span>
|
||||
{{ form_widget(form.name, {attr: {class: 'form-control', placeholder: 'e.g. Web server prod'}}) }}
|
||||
</div>
|
||||
{{ form_errors(form.name) }}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">Description <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-card-text"></i></span>
|
||||
{{ form_widget(form.description, {attr: {class: 'form-control', placeholder: 'Short description…', rows: 2}}) }}
|
||||
</div>
|
||||
{{ form_errors(form.description) }}
|
||||
</div>
|
||||
|
||||
<p class="form-section-title"><i class="bi bi-terminal me-1"></i>SSH Connection</p>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-8">
|
||||
<label class="form-label fw-semibold">IP Address</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-router"></i></span>
|
||||
{{ form_widget(form.ipAddress, {attr: {class: 'form-control', placeholder: '192.168.1.10'}}) }}
|
||||
</div>
|
||||
{{ form_errors(form.ipAddress) }}
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label fw-semibold">SSH Port</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-plug"></i></span>
|
||||
{{ form_widget(form.sshPort, {attr: {class: 'form-control'}}) }}
|
||||
</div>
|
||||
{{ form_errors(form.sshPort) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">SSH User</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-person-badge"></i></span>
|
||||
{{ form_widget(form.sshUser, {attr: {class: 'form-control', placeholder: 'e.g. root, deploy'}}) }}
|
||||
</div>
|
||||
{{ form_errors(form.sshUser) }}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">SSH Password</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text field-icon"><i class="bi bi-key"></i></span>
|
||||
{{ form_widget(form.sshPassword, {attr: {class: 'form-control', placeholder: '••••••••', id: 'ssh-password-input'}}) }}
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePwd()">
|
||||
<i class="bi bi-eye" id="pwd-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
{{ form_errors(form.sshPassword) }}
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 pt-2 border-top">
|
||||
<button type="submit" class="btn btn-primary px-4">
|
||||
<i class="bi bi-plus-lg me-1"></i>Ajouter serveur
|
||||
</button>
|
||||
<a href="{{ path('app_server_index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
<script>
|
||||
function togglePwd() {
|
||||
const input = document.getElementById('ssh-password-input');
|
||||
const eye = document.getElementById('pwd-eye');
|
||||
if (input.type === 'password') {
|
||||
input.type = 'text';
|
||||
eye.className = 'bi bi-eye-slash';
|
||||
} else {
|
||||
input.type = 'password';
|
||||
eye.className = 'bi bi-eye';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
502
templates/server/show.html.twig
Normal file
502
templates/server/show.html.twig
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
{% extends 'base.html.twig' %}
|
||||
{% block title %}{{ server.name }} - ServerControl{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<a href="{{ path('app_server_index') }}" class="text-muted text-decoration-none">
|
||||
<i class="bi bi-arrow-left me-1"></i>Retour aux serveur
|
||||
</a>
|
||||
<h1 class="h3 mt-1">{{ server.name }}</h1>
|
||||
</div>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<div>
|
||||
<a href="{{ path('app_server_edit', {id: server.id}) }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><strong>Server Info</strong></div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-5">IP Address</dt>
|
||||
<dd class="col-7"><code>{{ server.ipAddress }}</code></dd>
|
||||
<dt class="col-5">SSH Port</dt>
|
||||
<dd class="col-7">{{ server.sshPort }}</dd>
|
||||
<dt class="col-5">SSH User</dt>
|
||||
<dd class="col-7">{{ server.sshUser }}</dd>
|
||||
<dt class="col-5">Status</dt>
|
||||
<dd class="col-7">
|
||||
<span class="status-{{ server.lastStatus }}" id="server-status">
|
||||
<i class="bi bi-circle-fill me-1" style="font-size:.5rem;"></i>
|
||||
{{ server.lastStatus|capitalize }}
|
||||
</span>
|
||||
</dd>
|
||||
<dt class="col-5">Owner</dt>
|
||||
<dd class="col-7">{{ server.owner.pseudo }}</dd>
|
||||
<dt class="col-5">Added</dt>
|
||||
<dd class="col-7">{{ server.createdAt|date('d/m/Y') }}</dd>
|
||||
{% if server.description %}
|
||||
<dt class="col-5">Description</dt>
|
||||
<dd class="col-7">{{ server.description }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8 mb-4">
|
||||
<div class="card h-100" id="terminal-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong><i class="bi bi-terminal me-2"></i>Terminal SSH</strong>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="termClear()">
|
||||
<i class="bi bi-trash me-1"></i>Clear
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="terminal-titlebar">
|
||||
<span class="terminal-host">{{ server.sshUser }}@{{ server.ipAddress }}</span>
|
||||
</div>
|
||||
<div class="terminal-body" id="term-output">
|
||||
<div class="term-line term-info">Connecté a {{ server.name }} — tapez une commande et apuyez sur Entrée.</div>
|
||||
<div class="term-line term-warn">Note : utiliser sudo pour les opérations privilégiées — un mot de passe pourra être demandé.</div>
|
||||
</div>
|
||||
<div class="terminal-input-row">
|
||||
<span class="terminal-ps1">{{ server.sshUser }}@{{ server.name }}:~$ </span>
|
||||
<input class="terminal-cmd" id="term-input" type="text"
|
||||
placeholder="ex: ls /etc/nginx ou sudo systemctl status nginx"
|
||||
autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong><i class="bi bi-people me-2"></i>Operator Access</strong>
|
||||
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="collapse" data-bs-target="#addAccessForm">
|
||||
<i class="bi bi-plus-lg"></i> Grant access
|
||||
</button>
|
||||
</div>
|
||||
<div class="collapse" id="addAccessForm">
|
||||
<div class="card-body border-bottom">
|
||||
<form method="post" action="{{ path('app_access_new', {serverId: server.id}) }}" class="row g-2 align-items-end">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('add_access_' ~ server.id) }}">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label form-label-sm fw-semibold">User</label>
|
||||
<select name="user_id" class="form-select form-select-sm" required>
|
||||
<option value="">— select —</option>
|
||||
{% for u in assignableUsers %}
|
||||
<option value="{{ u.id }}">{{ u.pseudo }} ({{ u.email }})</option>
|
||||
{% else %}
|
||||
<option disabled>No more users to assign</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label form-label-sm fw-semibold">SSH User</label>
|
||||
<input class="form-control form-control-sm" name="ssh_user" placeholder="e.g. deploy" required>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label form-label-sm fw-semibold">SSH Password</label>
|
||||
<input class="form-control form-control-sm" name="ssh_password" type="password" placeholder="••••••" required>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button class="btn btn-sm btn-primary w-100">Grant</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if server.serverAccesses|length == 0 %}
|
||||
<p class="text-muted p-3 mb-0">Aucun opérateur assignée pour l'instant.</p>
|
||||
{% else %}
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr><th>Operator</th><th>Email</th><th>SSH User</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for access in server.serverAccesses %}
|
||||
<tr>
|
||||
<td>{{ access.user.pseudo }}</td>
|
||||
<td class="text-muted small">{{ access.user.email }}</td>
|
||||
<td><code>{{ access.sshUser }}</code></td>
|
||||
<td>
|
||||
<form method="post" action="{{ path('app_access_delete', {serverId: server.id, accessId: access.id}) }}"
|
||||
onsubmit="return confirm('Revoke access for {{ access.user.pseudo }}?')">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete_access_' ~ access.id) }}">
|
||||
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-person-x"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>Services</strong>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="collapse" data-bs-target="#addServiceForm">
|
||||
<i class="bi bi-plus-lg"></i> Ajouter service
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<div class="collapse" id="addServiceForm">
|
||||
<div class="card-body border-bottom">
|
||||
<form method="post" action="{{ path('app_service_new', {serverId: server.id}) }}" class="row g-2">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('add_service_' ~ server.id) }}">
|
||||
<div class="col-md-5">
|
||||
<input class="form-control form-control-sm" name="name" placeholder="Nom systemd (ex: nginx)" required>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<input class="form-control form-control-sm" name="display_name" placeholder="Nom d'affichage" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-sm btn-primary w-100">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card-body">
|
||||
{% if server.services|length == 0 %}
|
||||
<p class="text-muted mb-0">Aucun service configurée.</p>
|
||||
{% else %}
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
|
||||
{% for service in server.services %}
|
||||
<div class="col">
|
||||
<div class="card h-100 service-card">
|
||||
<div class="card-body pb-2">
|
||||
<div class="fw-semibold">{{ service.displayName }}</div>
|
||||
<code class="small text-muted">{{ service.name }}</code>
|
||||
<div class="mt-2">
|
||||
<span class="badge service-status-badge"
|
||||
id="svc-status-{{ service.name|replace({'.': '-', '@': '-', ':': '-'}) }}"
|
||||
data-service="{{ service.name }}">
|
||||
<span class="spinner-border spinner-border-sm" style="width:.7em;height:.7em;border-width:1px;"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex gap-1 flex-wrap">
|
||||
<button class="btn btn-sm btn-success service-action"
|
||||
data-server="{{ server.id }}" data-service="{{ service.name }}"
|
||||
data-action="start" data-token="{{ csrf_token('server_action') }}"
|
||||
title="Démarrer">
|
||||
<i class="bi bi-play-fill"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger service-action"
|
||||
data-server="{{ server.id }}" data-service="{{ service.name }}"
|
||||
data-action="stop" data-token="{{ csrf_token('server_action') }}"
|
||||
title="Arrêter">
|
||||
<i class="bi bi-stop-fill"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-warning service-action"
|
||||
data-server="{{ server.id }}" data-service="{{ service.name }}"
|
||||
data-action="restart" data-token="{{ csrf_token('server_action') }}"
|
||||
title="Redémarrer">
|
||||
<i class="bi bi-arrow-repeat"></i>
|
||||
</button>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<form method="post"
|
||||
action="{{ path('app_service_delete', {serverId: server.id, serviceId: service.id}) }}"
|
||||
onsubmit="return confirm('Delete service « {{ service.displayName }} »?')"
|
||||
class="ms-auto">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete_service_' ~ service.id) }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Supprimer">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="sudoModal" tabindex="-1" aria-labelledby="sudoModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-sm modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="sudoModalLabel">
|
||||
<i class="bi bi-shield-lock me-2 text-warning"></i>Mot de passe sudo requis
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small mb-3">
|
||||
La commande <code id="sudo-cmd-preview"></code> nécessite des privilèges élevés.
|
||||
</p>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-key"></i></span>
|
||||
<input type="password" class="form-control" id="sudo-password-input"
|
||||
placeholder="mot de passe sudo" autocomplete="current-password">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="toggleSudoPwd()">
|
||||
<i class="bi bi-eye" id="sudo-pwd-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="sudo-error" class="text-danger small mt-2 d-none">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>Mot de passe incorect, réessayez.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Annuler</button>
|
||||
<button type="button" class="btn btn-warning btn-sm" id="sudo-confirm-btn">
|
||||
<i class="bi bi-send me-1"></i>Exécuter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<strong>Journaux récent</strong>
|
||||
<a href="{{ path('app_log_index', {server: server.id}) }}" class="btn btn-sm btn-link">Voir tout</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if server.actionLogs|length == 0 %}
|
||||
<p class="text-muted p-3 mb-0">Aucun journaux pour l'instant.</p>
|
||||
{% else %}
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr><th>Date</th><th>Action</th><th>Status</th><th>User</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in server.actionLogs|slice(0, 10) %}
|
||||
<tr>
|
||||
<td>{{ log.executedAt|date('d/m/Y H:i') }}</td>
|
||||
<td><code>{{ log.actionType }}</code></td>
|
||||
<td>
|
||||
<span class="badge bg-{{ log.status == 'success' ? 'success' : 'danger' }}">
|
||||
{{ log.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ log.user ? log.user.pseudo : 'deleted' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
<script>
|
||||
const TERM_CSRF = '{{ csrf_token('terminal_' ~ server.id) }}';
|
||||
const ACTION_CSRF = '{{ csrf_token('server_action') }}';
|
||||
const SERVER_ID = {{ server.id }};
|
||||
const SSH_USER = '{{ server.sshUser }}';
|
||||
const SERVER_NAME = '{{ server.name }}';
|
||||
|
||||
const cmdHistory = [];
|
||||
let historyPos = -1;
|
||||
|
||||
function termLine(text, cls) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'term-line ' + (cls || '');
|
||||
d.textContent = text;
|
||||
return d;
|
||||
}
|
||||
function termAppend(text, cls) {
|
||||
const out = document.getElementById('term-output');
|
||||
if (!text) return;
|
||||
text.split('\n').forEach(l => { if (l !== '') out.appendChild(termLine(l, cls)); });
|
||||
out.scrollTop = out.scrollHeight;
|
||||
}
|
||||
function termClear() {
|
||||
document.getElementById('term-output').innerHTML =
|
||||
'<div class="term-line term-info">— Terminal effacé —</div>';
|
||||
}
|
||||
function removePending(marker) {
|
||||
const out = document.getElementById('term-output');
|
||||
const el = [...out.querySelectorAll('.term-line.term-info')].findLast(e => e.textContent === marker);
|
||||
if (el) el.remove();
|
||||
}
|
||||
function showResult(data, isOk) {
|
||||
const cls = isOk ? 'term-success' : 'term-error';
|
||||
if (data.output) termAppend(data.output.trimEnd(), cls);
|
||||
termAppend(isOk ? '[OK]' : '[FAILED]', cls);
|
||||
}
|
||||
|
||||
function loadServiceStatuses() {
|
||||
fetch(`/server/${SERVER_ID}/services/status`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
Object.entries(data).forEach(([name, status]) => {
|
||||
const safeId = name.replace(/[.@:]/g, '-');
|
||||
const el = document.getElementById(`svc-status-${safeId}`);
|
||||
if (!el) return;
|
||||
if (status === 'active') {
|
||||
el.className = 'badge bg-success';
|
||||
el.textContent = 'Actif';
|
||||
} else if (status === 'inactive') {
|
||||
el.className = 'badge bg-danger';
|
||||
el.textContent = 'Inactif';
|
||||
} else {
|
||||
el.className = 'badge bg-secondary';
|
||||
el.textContent = 'Inconnu';
|
||||
}
|
||||
});
|
||||
}).catch(() => {
|
||||
document.querySelectorAll('.service-status-badge').forEach(el => {
|
||||
el.className = 'badge bg-secondary';
|
||||
el.textContent = 'Inconnu';
|
||||
});
|
||||
});
|
||||
}
|
||||
loadServiceStatuses();
|
||||
|
||||
let pending = null;
|
||||
const sudoModal = new bootstrap.Modal(document.getElementById('sudoModal'));
|
||||
|
||||
function showSudoModal(pendingData, isRetry) {
|
||||
pending = pendingData;
|
||||
const label = pendingData.type === 'action'
|
||||
? `sudo systemctl ${pendingData.action} ${pendingData.service}`
|
||||
: pendingData.command;
|
||||
document.getElementById('sudo-cmd-preview').textContent = label;
|
||||
document.getElementById('sudo-password-input').value = '';
|
||||
document.getElementById('sudo-error').classList.toggle('d-none', !isRetry);
|
||||
sudoModal.show();
|
||||
setTimeout(() => document.getElementById('sudo-password-input').focus(), 350);
|
||||
}
|
||||
|
||||
document.getElementById('sudo-confirm-btn').addEventListener('click', submitSudo);
|
||||
document.getElementById('sudo-password-input').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') submitSudo();
|
||||
});
|
||||
|
||||
function submitSudo() {
|
||||
const pwd = document.getElementById('sudo-password-input').value.trim();
|
||||
if (!pwd) return;
|
||||
sudoModal.hide();
|
||||
termAppend('[sudo] authentification en cours…', 'term-warn');
|
||||
if (pending.type === 'terminal') {
|
||||
execTerminalCommand(pending.command, pwd);
|
||||
} else {
|
||||
execServiceAction(pending.action, pending.service, pending.serverId, pwd);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSudoPwd() {
|
||||
const inp = document.getElementById('sudo-password-input');
|
||||
const eye = document.getElementById('sudo-pwd-eye');
|
||||
inp.type = inp.type === 'password' ? 'text' : 'password';
|
||||
eye.className = inp.type === 'password' ? 'bi bi-eye' : 'bi bi-eye-slash';
|
||||
}
|
||||
|
||||
function execTerminalCommand(command, sudoPassword = null) {
|
||||
const form = new FormData();
|
||||
form.append('_token', TERM_CSRF);
|
||||
form.append('command', command);
|
||||
if (sudoPassword) form.append('sudo_password', sudoPassword);
|
||||
|
||||
return fetch(`/server/${SERVER_ID}/terminal`, { method: 'POST', body: form })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
removePending('…');
|
||||
if (data.needs_sudo) {
|
||||
if (sudoPassword) {
|
||||
termAppend('[sudo] mot de passe incorrect', 'term-error');
|
||||
setTimeout(() => showSudoModal({ type: 'terminal', command }, true), 300);
|
||||
} else {
|
||||
showSudoModal({ type: 'terminal', command }, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
showResult(data, data.success);
|
||||
}).catch(() => termAppend('Network error.', 'term-error'));
|
||||
}
|
||||
|
||||
const termInput = document.getElementById('term-input');
|
||||
termInput.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const cmd = this.value.trim();
|
||||
if (!cmd) return;
|
||||
cmdHistory.unshift(cmd);
|
||||
if (cmdHistory.length > 50) cmdHistory.pop();
|
||||
historyPos = -1;
|
||||
this.value = '';
|
||||
termAppend(`${SSH_USER}@${SERVER_NAME}:~$ ${cmd}`, 'term-prompt');
|
||||
if (cmd === 'clear' || cmd === 'cls') { termClear(); return; }
|
||||
termAppend('…', 'term-info');
|
||||
execTerminalCommand(cmd);
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (historyPos < cmdHistory.length - 1) termInput.value = cmdHistory[++historyPos];
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
historyPos > 0 ? termInput.value = cmdHistory[--historyPos] : (historyPos = -1, termInput.value = '');
|
||||
}
|
||||
});
|
||||
document.querySelector('.terminal-body').addEventListener('click', () => termInput.focus());
|
||||
|
||||
function execServiceAction(action, service, serverId, sudoPassword = null) {
|
||||
const form = new FormData();
|
||||
form.append('action', action);
|
||||
form.append('service', service);
|
||||
form.append('_token', ACTION_CSRF);
|
||||
if (sudoPassword) form.append('sudo_password', sudoPassword);
|
||||
|
||||
return fetch(`/server/${serverId}/action`, { method: 'POST', body: form })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
removePending('Exécution…');
|
||||
if (data.needs_sudo) {
|
||||
if (sudoPassword) {
|
||||
termAppend('[sudo] mot de passe incorrect', 'term-error');
|
||||
setTimeout(() => showSudoModal({ type: 'action', action, service, serverId }, true), 300);
|
||||
} else {
|
||||
showSudoModal({ type: 'action', action, service, serverId }, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
showResult(data, data.status === 'success');
|
||||
loadServiceStatuses();
|
||||
}).catch(() => termAppend('Network error.', 'term-error'));
|
||||
}
|
||||
|
||||
document.querySelectorAll('.service-action').forEach(btn => {
|
||||
btn.addEventListener('click', function () {
|
||||
const action = this.dataset.action;
|
||||
const service = this.dataset.service;
|
||||
const srvId = this.dataset.server;
|
||||
termAppend(`${SSH_USER}@${SERVER_NAME}:~$ sudo systemctl ${action} ${service}`, 'term-prompt');
|
||||
termAppend('Exécution…', 'term-info');
|
||||
execServiceAction(action, service, srvId);
|
||||
});
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
fetch(`/server/${SERVER_ID}/status`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const el = document.getElementById('server-status');
|
||||
if (el) {
|
||||
el.className = `status-${data.status}`;
|
||||
el.innerHTML = `<i class="bi bi-circle-fill me-1" style="font-size:.5rem;"></i>${data.status.charAt(0).toUpperCase() + data.status.slice(1)}`;
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, 30000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue