mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-10 04:06:47 +00:00
feat(instances): the VM list on Bootstrap's pills, badges and menus, its actions by the user's rights
- The state filter is a row of pills, All and each state, counting the VMs the search finds (the chosen state aside); a state without any is hidden unless chosen (All never), and a status line tells screen readers how many VMs match. Every state keeps its pill. - A labelled "New VM" button replaces the bare green plus; "Find IP" says "Find IP / MAC", which it finds. - States are badges; a shut-off VM is grey, not red: it is not a fault. - A VM's cell holds its name, its title, and for those who see owners its first owner as a badge, "+N" for the others (named for screen readers and on hover); the separate user column goes. - Each row has the same button group for a superuser or the VM's owners, from the actions the user may use (states.allowed_actions), all of it disabled on a VM an administrator paused; view-only users get none. It holds Console, the power action the state calls for (Start, Resume, Shut down, or Clone for a template) and a menu of the others (Suspend, Force off, Power cycle), what does not apply now disabled. The power button is an icon, named on hover and for screen readers, so every group is as wide and they line up from row to row; the power cut confirmations stay. On a phone Console shows its icon only too. - A grouped host's row has its name, a Connected or Not connected badge, its VM count and a labelled host memory bar; an unreachable host's row is table-warning. Its collapse control and the one for all hosts are buttons, with aria-expanded kept. - vCPUs and memory hide on a phone in both layouts, and table cells keep Bootstrap's own padding there: Zephyr doubles it and a list's last column no longer fit the screen.
This commit is contained in:
parent
2d80f21a4a
commit
3aac8f51f2
18 changed files with 295 additions and 153 deletions
|
|
@ -12,7 +12,7 @@ STATES = {
|
|||
2: (_("Blocked"), "success"), # running, waiting for a resource
|
||||
3: (_("Suspended"), "warning"),
|
||||
4: (_("Shutting down"), "warning"),
|
||||
5: (_("Off"), "danger"),
|
||||
5: (_("Off"), "secondary"), # shut off is not a fault
|
||||
6: (_("Crashed"), "danger"),
|
||||
7: (_("PM suspended"), "warning"), # suspended by the guest
|
||||
}
|
||||
|
|
@ -38,6 +38,12 @@ def state_label(state):
|
|||
return UNKNOWN if state is None else STATES.get(state, STATES[0])
|
||||
|
||||
|
||||
def manages(user, instance):
|
||||
"""Superusers and a VM's owners: the users its power actions are for."""
|
||||
# the lists prefetch userinstance_set
|
||||
return user.is_superuser or any(ui.user_id == user.id for ui in instance.userinstance_set.all())
|
||||
|
||||
|
||||
def allowed_actions(user, instance, state):
|
||||
"""
|
||||
The power actions the state allows and the user may use: superusers all,
|
||||
|
|
@ -50,8 +56,7 @@ def allowed_actions(user, instance, state):
|
|||
return {"clone"} if user.has_perm("instances.clone_instances") else set()
|
||||
if user.is_superuser:
|
||||
return STATE_ACTIONS.get(state, set())
|
||||
# the lists prefetch userinstance_set
|
||||
if not any(ui.user_id == user.id for ui in instance.userinstance_set.all()):
|
||||
if not manages(user, instance):
|
||||
return set()
|
||||
if state == 3:
|
||||
return set()
|
||||
|
|
|
|||
|
|
@ -5,22 +5,16 @@
|
|||
{% block page_heading %}{% trans "Instances" %}{% endblock page_heading %}
|
||||
|
||||
{% block page_heading_extra %}
|
||||
{% if request.user.is_superuser %}
|
||||
{% include 'create_inst_block.html' %}
|
||||
{% endif %}
|
||||
{# the list's filters (state, text) and the address search, as one control #}
|
||||
<div class="float-end m-1">
|
||||
<div class="input-group">
|
||||
<select id="state-filter" class="form-select flex-grow-0 w-auto" aria-label="{% trans 'State' %}">
|
||||
<option value="">{% trans 'All states' %}</option>
|
||||
{% for state in states %}
|
||||
<option>{{ state }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="d-flex flex-wrap justify-content-end gap-2 mt-1">
|
||||
{# the list's search and the address search, as one control #}
|
||||
<div class="input-group w-auto">
|
||||
<input id="filter" class="form-control" type="text" placeholder="{% trans 'Search' %}" aria-label="{% trans 'Search' %}">
|
||||
<button type="button" class="btn btn-outline-secondary" id="find-address-open"
|
||||
data-bs-toggle="modal" data-bs-target="#find-address">{% trans "Find IP" %}</button>
|
||||
data-bs-toggle="modal" data-bs-target="#find-address">{% trans "Find IP / MAC" %}</button>
|
||||
</div>
|
||||
{% if request.user.is_superuser %}
|
||||
{% include 'create_inst_block.html' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock page_heading_extra %}
|
||||
|
||||
|
|
@ -45,6 +39,23 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
<div class="col-lg-12">
|
||||
{% if instances.exists %}
|
||||
{# the state filter: table_filter.js counts each state's VMs the search finds #}
|
||||
<input type="hidden" id="state-filter" value="">
|
||||
<nav class="mb-3" aria-label="{% trans "VM states" %}">
|
||||
<div class="nav nav-pills flex-wrap gap-1" id="state-pills">
|
||||
<button type="button" class="nav-link active py-1 px-2" data-pill-state="" aria-pressed="true">
|
||||
{% trans "All" %} <span class="badge text-bg-light ms-1" data-count-state=""></span>
|
||||
</button>
|
||||
{% for state in states %}
|
||||
<button type="button" class="nav-link py-1 px-2" data-pill-state="{{ state }}" aria-pressed="false" hidden>
|
||||
{{ state }} <span class="badge text-bg-light ms-1" data-count-state="{{ state }}"></span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</nav>
|
||||
<div id="filter-status" class="visually-hidden" role="status" data-text="{% trans "%(shown)s of %(all)s VMs match" %}"></div>
|
||||
{% endif %}
|
||||
{% if not instances.exists %}
|
||||
<p class="text-center text-body-secondary py-4">{% trans "There are no VMs here yet." %}</p>
|
||||
{% elif app_settings.VIEW_INSTANCES_LIST_STYLE == 'grouped' and request.user.is_superuser %}
|
||||
|
|
|
|||
|
|
@ -4,48 +4,51 @@
|
|||
<table class="table table-hover align-middle" data-sortable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" data-sort-method="none"><a class="link-primary" href="#" id="hide-all-instances" onclick="hide_all_host_instances(); return false;">#</a></th>
|
||||
<th scope="col">{% trans "Name" %}<br>{% trans "Description" %}</th>
|
||||
<th scope="col" class="d-none d-sm-table-cell">{% trans "User"%}</th>
|
||||
<th scope="col" data-sort-method="none">
|
||||
<button type="button" class="btn btn-sm btn-link p-0" id="hide-all-instances" onclick="hide_all_host_instances()" aria-expanded="true"
|
||||
aria-label="{% trans "Collapse or expand every host" %}">{% bs_icon 'arrows-collapse' %}</button>
|
||||
</th>
|
||||
<th scope="col">{% trans "Name" %}</th>
|
||||
<th scope="col">{% trans "Status" %}</th>
|
||||
{% if app_settings.VM_DRBD_STATUS == 'True' %}
|
||||
<th scope="col">{% trans "Role/Disk" %}</th>
|
||||
{% endif %}
|
||||
<th scope="col" class="d-none d-sm-table-cell" data-sort-method="number">{% trans "VCPU" %}</th>
|
||||
<th scope="col" class="d-none d-sm-table-cell" data-sort-method="number">{% trans "vCPUs" %}</th>
|
||||
<th scope="col" class="d-none d-sm-table-cell" data-sort-method="number">{% trans "Memory" %}</th>
|
||||
<th scope="col" style="width:200px;" data-sort-method="none">{% trans "Actions" %} & {% trans "Mem Usage" %}</th>
|
||||
<th scope="col" data-sort-method="none"><span class="visually-hidden">{% trans "Actions" %}</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
{% for compute in computes %}
|
||||
<tbody class="searchable" data-host="{{ compute.name }}">
|
||||
<tbody class="searchable" data-host="{{ compute.name }}" id="host-vms-{{ compute.id }}">
|
||||
{% if compute.instance_set.count > 0 %}
|
||||
<tr class="table-secondary fw-bold border-bottom border-dark" data-sort-method="none" data-group-header>
|
||||
<tr class="{% if compute.status is True %}table-secondary{% else %}table-warning{% endif %}" data-sort-method="none" data-group-header>
|
||||
<td>
|
||||
<span id="collapse_host_instances_{{ compute.id }}" onclick="hide_host_instances('{{ compute.id }}');" class="bi bi-chevron-up" ></span>
|
||||
<button type="button" class="btn btn-sm btn-link p-0" onclick="hide_host_instances('{{ compute.id }}')"
|
||||
aria-expanded="true" aria-controls="host-vms-{{ compute.id }}"
|
||||
aria-label="{% blocktrans with name=compute.name %}Collapse or expand {{ name }}{% endblocktrans %}">
|
||||
<span id="collapse_host_instances_{{ compute.id }}" class="bi bi-chevron-up" aria-hidden="true"></span>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<a class="link-dark" href="{% url 'overview' compute.id %}">{{ compute.name }}</a>
|
||||
<span id="inst_count_badge_{{ compute.id }}" class="badge bg-secondary d-none">{{ compute.instance_set.count }}</span>
|
||||
<td colspan="{% if app_settings.VM_DRBD_STATUS == 'True' %}3{% else %}2{% endif %}">
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<a class="fw-semibold" href="{% url 'overview' compute.id %}">{{ compute.name }}</a>
|
||||
{% if compute.status is True %}
|
||||
<span class="badge bg-success-subtle text-success-emphasis">{% trans "Connected" %}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger-subtle text-danger-emphasis">{% trans "Not connected" %}</span>
|
||||
{% endif %}
|
||||
<span class="small text-body-secondary">{% blocktrans count counter=compute.instance_set.count %}{{ counter }} VM{% plural %}{{ counter }} VMs{% endblocktrans %}</span>
|
||||
<span id="inst_count_badge_{{ compute.id }}" class="badge text-bg-secondary d-none">{% trans "collapsed" %}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="d-none d-sm-table-cell"></td>
|
||||
<td>
|
||||
{% if compute.status is True %}
|
||||
<span class="badge bg-success-subtle text-success-emphasis">{% trans "Connected" %}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger-subtle text-danger-emphasis">{% trans "Not Connected" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if app_settings.VM_DRBD_STATUS == 'True' %}
|
||||
<td></td>
|
||||
{% endif %}
|
||||
{% if compute.status is True %}
|
||||
<td class="d-none d-sm-table-cell text-center">{{ compute.cpu_count }}</td>
|
||||
<td class="d-none d-sm-table-cell text-right">{{ compute.ram_size|filesizeformat }}</td>
|
||||
<td class="d-none d-sm-table-cell">{{ compute.cpu_count }}</td>
|
||||
<td class="d-none d-sm-table-cell">{{ compute.ram_size|filesizeformat }}</td>
|
||||
<td>
|
||||
<div class="progress">
|
||||
<div class="progress-bar bg-success" role="progressbar" aria-label="{% trans "Mem Usage" %}" style="width: {{ compute.ram_usage }}%"
|
||||
aria-valuenow="{{ compute.ram_usage }}" aria-valuemin="0" aria-valuemax="100">{{ compute.ram_usage }}%
|
||||
</div>
|
||||
<div class="small text-body-secondary">{% blocktrans with percent=compute.ram_usage %}host memory {{ percent }}% in use{% endblocktrans %}</div>
|
||||
<div class="progress" role="progressbar" style="--bs-progress-height: 0.3rem;" aria-label="{% trans "Host memory in use" %}"
|
||||
aria-valuenow="{{ compute.ram_usage }}" aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="progress-bar" style="width: {{ compute.ram_usage }}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
{% else %}
|
||||
|
|
@ -58,21 +61,7 @@
|
|||
{# searched for its name, title and owners, not its buttons #}
|
||||
<tr host="{{ compute.id }}" data-search="{{ instance.name }}{% if instance.info %} {{ instance.title }}{% endif %}{% for owner in instance.userinstance_set.all %} {{ owner.user }}{% endfor %}">
|
||||
<td></td>
|
||||
<td>
|
||||
<a class="link-primary" href="{% url 'instances:instance' instance.id %}">{{ instance.name }}</a>
|
||||
<br>
|
||||
{% if instance.info %}<p class="m-0 small fst-italic">{{ instance.title }}</p>{% endif %}
|
||||
</td>
|
||||
<td class="d-none d-sm-table-cell">
|
||||
<em>
|
||||
{% if instance.userinstance_set.all.count > 0 %}
|
||||
{{ instance.userinstance_set.all.0.user }}
|
||||
{% if instance.userinstance_set.all.count > 1 %}
|
||||
(+{{ instance.userinstance_set.all.count|add:"-1" }})
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</em>
|
||||
</td>
|
||||
<td>{% include 'instance_name_cell.html' with owners=True %}</td>
|
||||
<td>
|
||||
{% include 'instance_state.html' %}
|
||||
</td>
|
||||
|
|
@ -83,7 +72,7 @@
|
|||
{% endif %}
|
||||
<td class="d-none d-sm-table-cell" data-sort="{{ instance.info.3 }}">{{ instance.info.3 }}</td>
|
||||
<td class="d-none d-sm-table-cell" data-sort="{{ instance.info.2 }}">{% if instance.info %}{% widthratio instance.info.2 1024 1 %} MiB{% endif %}</td>
|
||||
<td class="text-nowrap">
|
||||
<td>
|
||||
{% include 'instance_actions.html' %}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -100,8 +89,11 @@
|
|||
// search (table_filter.js, the hidden attribute) can still hide them too
|
||||
function set_host_collapsed(tbody, collapsed) {
|
||||
$(tbody).toggleClass('host-collapsed', collapsed);
|
||||
$(tbody).find('[aria-controls]').attr('aria-expanded', !collapsed);
|
||||
$(tbody).find('span[id^=collapse_host_instances_]').toggleClass('bi-chevron-down', collapsed).toggleClass('bi-chevron-up', !collapsed);
|
||||
$(tbody).find('span[id^=inst_count_badge_]').toggleClass('d-none', !collapsed);
|
||||
// expanded while any host is, as the next click collapses them all
|
||||
$('#hide-all-instances').attr('aria-expanded', $('tbody.searchable').not('.host-collapsed').length > 0);
|
||||
}
|
||||
// every host the same way: collapsed, unless all already are
|
||||
function hide_all_host_instances() {
|
||||
|
|
|
|||
|
|
@ -3,42 +3,28 @@
|
|||
<table class="table table-hover align-middle" data-sortable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{% trans 'Name' %}<br>{% trans 'Description' %}</th>
|
||||
<th scope="col">{% trans 'Name' %}</th>
|
||||
{% if show_hosts %}
|
||||
<th scope="col">{% trans 'Host' %}<br>{% trans 'User' %}</th>
|
||||
<th scope="col">{% trans 'Host' %}</th>
|
||||
{% endif %}
|
||||
<th scope="col">{% trans 'Status' %}</th>
|
||||
{% if app_settings.VM_DRBD_STATUS == 'True' %}
|
||||
<th scope="col">{% trans 'Role/Disk' %}</th>
|
||||
{% endif %}
|
||||
<th scope="col" data-sort-method="number">{% trans 'VCPU' %}</th>
|
||||
<th scope="col" data-sort-method="number">{% trans 'Memory' %}</th>
|
||||
<th scope="col" data-sort-method="none">{% trans 'Actions' %}</th>
|
||||
<th scope="col" class="d-none d-sm-table-cell" data-sort-method="number">{% trans 'vCPUs' %}</th>
|
||||
<th scope="col" class="d-none d-sm-table-cell" data-sort-method="number">{% trans 'Memory' %}</th>
|
||||
<th scope="col" data-sort-method="none"><span class="visually-hidden">{% trans 'Actions' %}</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="searchable">
|
||||
{% for instance in instances %}
|
||||
{# searched for its name, title, host and owners, not its buttons #}
|
||||
<tr data-search="{{ instance.name }}{% if instance.info %} {{ instance.title }}{% endif %}{% if show_hosts %} {{ instance.compute.name }}{% for owner in instance.userinstance_set.all %} {{ owner.user }}{% endfor %}{% endif %}">
|
||||
<td>
|
||||
<a class="link-primary" href="{% url 'instances:instance' instance.id %}">
|
||||
{{ instance.name }}
|
||||
</a>
|
||||
<br>
|
||||
{% if instance.info %}<p class="m-0 small fst-italic">{{ instance.title }}</p>{% endif %}
|
||||
</td>
|
||||
<td>{% include 'instance_name_cell.html' with owners=show_hosts %}</td>
|
||||
{% if show_hosts %}
|
||||
<td>
|
||||
{# the host pages are a superuser's #}
|
||||
{% if request.user.is_superuser %}<a href="{% url 'overview' instance.compute.id %}">{{ instance.compute.name }}</a>{% else %}{{ instance.compute.name }}{% endif %}<br>
|
||||
<small><em>
|
||||
{% if instance.userinstance_set.all.count > 0 %}
|
||||
{{ instance.userinstance_set.all.0.user }}
|
||||
{% if instance.userinstance_set.all.count > 1 %}
|
||||
(+{{ instance.userinstance_set.all.count|add:"-1" }})
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</em></small>
|
||||
{% if request.user.is_superuser %}<a href="{% url 'overview' instance.compute.id %}">{{ instance.compute.name }}</a>{% else %}{{ instance.compute.name }}{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
<td>
|
||||
|
|
@ -49,9 +35,9 @@
|
|||
{% if instance.drbd == "Primary/OK" or instance.drbd == "Secondary/OK" %}<span class="badge bg-success-subtle text-success-emphasis">{% else %}<span class="badge bg-danger-subtle text-danger-emphasis">{% endif %}{{ instance.drbd }}</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
<td data-sort="{{ instance.info.3 }}">{{ instance.info.3 }}</td>
|
||||
<td data-sort="{{ instance.info.2 }}">{% if instance.info %}{% widthratio instance.info.2 1024 1 %} MiB{% endif %}</td>
|
||||
<td class="text-nowrap">
|
||||
<td class="d-none d-sm-table-cell" data-sort="{{ instance.info.3 }}">{{ instance.info.3 }}</td>
|
||||
<td class="d-none d-sm-table-cell" data-sort="{{ instance.info.2 }}">{% if instance.info %}{% widthratio instance.info.2 1024 1 %} MiB{% endif %}</td>
|
||||
<td>
|
||||
{% include 'instance_actions.html' %}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
{% load i18n %}
|
||||
{% load bootstrap_icons %}
|
||||
<a href="#AddInstance" type="button" class="btn btn-success mt-1 float-end" title="{% trans "New Instance" %}" data-bs-toggle="modal">
|
||||
<span aria-hidden="true">{% bs_icon 'plus-circle-fill'%} </span>
|
||||
</a>
|
||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#AddInstance">
|
||||
<span aria-hidden="true">{% bs_icon 'plus-lg' %}</span> {% trans "New VM" %}
|
||||
</button>
|
||||
|
||||
<div class="modal fade" id="AddInstance" tabindex="-1" aria-labelledby="AddInstanceTitle" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-scrollable modal-lg">
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
<div>
|
||||
<div>
|
||||
{% vm_state instance.status as state %}
|
||||
<span class="badge bg-{{ state.1 }}">{{ state.0 }}</span>
|
||||
<span class="badge text-bg-{{ state.1 }}">{{ state.0 }}</span>
|
||||
|
|
||||
<span
|
||||
{%if instance.guest_agent %}
|
||||
|
|
|
|||
|
|
@ -1,69 +1,65 @@
|
|||
{% load i18n %}
|
||||
{% load bootstrap_icons %}
|
||||
{% load vm_states %}
|
||||
{# the actions this user may use on the VM now (states.allowed_actions), as one group of the same shape in every row: the console, the power action the state calls for (its icon only, named on hover), and a menu of the others; what does not apply now is disabled, all of it for a VM its owner cannot use now (one an administrator paused). The console's word is hidden on a phone. #}
|
||||
{% vm_actions instance as actions %}
|
||||
{% if actions %}
|
||||
<div class="btn-group" role="group" aria-label="Instance actions">
|
||||
{% vm_manages instance as manages %}
|
||||
{% if actions or manages and instance.info %}
|
||||
<div class="d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="{% blocktrans with name=instance.name %}Actions for {{ name }}{% endblocktrans %}">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" data-url="{% url 'console' instance.compute_id instance.uuid %}" data-window="console-{{ instance.uuid }}"
|
||||
onclick="window.open(this.dataset.url, this.dataset.window, 'width=850,height=650')" aria-label="{% trans "Console" %}"{% if "console" not in actions %} disabled{% endif %}>{% bs_icon 'display' %} <span class="d-none d-sm-inline">{% trans "Console" %}</span></button>
|
||||
{% if "clone" in actions %}
|
||||
<a href="{% url 'instances:instance' instance.id %}#clone" class="btn btn-sm btn-secondary" title="{% trans "Clone" %}">
|
||||
{% bs_icon 'copy' %}
|
||||
</a>
|
||||
<a href="{% url 'instances:instance' instance.id %}#clone" class="btn btn-sm btn-outline-secondary" title="{% trans "Clone" %}" aria-label="{% trans "Clone" %}">{% bs_icon 'copy' %}</a>
|
||||
{% elif "poweron" in actions %}
|
||||
<form action="{% url 'instances:poweron' instance.id %}" method="post" class="d-inline" onsubmit="showPleaseWaitDialog()">
|
||||
<form action="{% url 'instances:poweron' instance.id %}" method="post" class="btn-group" onsubmit="showPleaseWaitDialog()">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-sm btn-secondary" title="{% trans "Power On" %}">{% bs_icon 'play-fill' %}</button>
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary" title="{% trans "Start" %}" aria-label="{% trans "Start" %}">{% bs_icon 'play-fill' %}</button>
|
||||
</form>
|
||||
{% elif "resume" in actions %}
|
||||
<form action="{% url 'instances:resume' instance.id %}" method="post" class="d-inline">
|
||||
<form action="{% url 'instances:resume' instance.id %}" method="post" class="btn-group">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-sm btn-secondary" title="{% trans "Resume" %}">{% bs_icon 'play-fill' %}</button>
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary" title="{% trans "Resume" %}" aria-label="{% trans "Resume" %}">{% bs_icon 'play-fill' %}</button>
|
||||
</form>
|
||||
{% elif "poweroff" in actions %}
|
||||
<form action="{% url 'instances:poweroff' instance.id %}" method="post" class="btn-group">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary" title="{% trans "Shut down" %}" aria-label="{% trans "Shut down" %}">{% bs_icon 'stop-fill' %}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<button class="btn btn-sm btn-secondary disabled" title="{% trans "Power On" %}" disabled>{% bs_icon 'play-fill' %}</button>
|
||||
{% endif %}
|
||||
|
||||
{% if "suspend" in actions %}
|
||||
<form action="{% url 'instances:suspend' instance.id %}" method="post" class="d-inline">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-sm btn-secondary" title="{% trans "Suspend" %}">{% bs_icon 'pause-fill' %}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<button class="btn btn-sm btn-secondary disabled" title="{% trans "Suspend" %}" disabled>{% bs_icon 'pause-fill' %}</button>
|
||||
{% endif %}
|
||||
|
||||
{% if "poweroff" in actions %}
|
||||
<form action="{% url 'instances:poweroff' instance.id %}" method="post" class="d-inline">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-sm btn-secondary" title="{% trans "Power Off" %}">{% bs_icon 'power' %}</button>
|
||||
</form>
|
||||
{% elif "force_off" in actions %}
|
||||
<form action="{% url 'instances:force_off' instance.id %}" method="post" class="d-inline">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-sm btn-secondary" title="{% trans "Force Off" %}"
|
||||
data-confirm="{% blocktrans with name=instance.name %}Force off {{ name }}? It stops at once, as after a power cut; what the guest has not written to disk is lost.{% endblocktrans %}"
|
||||
onclick="return confirm(this.dataset.confirm)">{% bs_icon 'power' %}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<button class="btn btn-sm btn-secondary disabled" title="{% trans "Power Off" %}" disabled>{% bs_icon 'power' %}</button>
|
||||
{% endif %}
|
||||
|
||||
{% if "powercycle" in actions %}
|
||||
<form action="{% url 'instances:powercycle' instance.id %}" method="post" class="d-inline">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-sm btn-secondary" title="{% trans "Power Cycle" %}"
|
||||
data-confirm="{% blocktrans with name=instance.name %}Power cycle {{ name }}? It stops at once, as after a power cut, and starts again; what the guest has not written to disk is lost.{% endblocktrans %}"
|
||||
onclick="return confirm(this.dataset.confirm)">{% bs_icon 'repeat' %}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<button class="btn btn-sm btn-secondary disabled" title="{% trans "Power Cycle" %}" disabled>{% bs_icon 'repeat' %}</button>
|
||||
{% endif %}
|
||||
|
||||
{% if "console" in actions %}
|
||||
<button class="btn btn-sm btn-secondary" type="button" data-url="{% url 'console' instance.compute_id instance.uuid %}" data-window="console-{{ instance.uuid }}"
|
||||
onclick="window.open(this.dataset.url, this.dataset.window, 'width=850,height=650')"
|
||||
title="{% trans "Console" %}">{% bs_icon 'eye-fill' %}</button>
|
||||
{% else %}
|
||||
<button class="btn btn-sm btn-secondary disabled" title="{% trans "VNC Console" %}" disabled>{% bs_icon 'eye-fill' %}</button>
|
||||
{% if instance.info.0 == 3 %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" title="{% trans "Resume" %}" aria-label="{% trans "Resume" %}" disabled>{% bs_icon 'play-fill' %}</button>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" title="{% trans "Start" %}" aria-label="{% trans "Start" %}" disabled>{% bs_icon 'play-fill' %}</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="btn-group">
|
||||
{# fixed: the menu would be cut off by the table's scrolling box #}
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="dropdown" aria-expanded="false"
|
||||
data-bs-popper-config='{"strategy": "fixed"}' aria-label="{% trans "More actions" %}"
|
||||
{% if "suspend" not in actions and "force_off" not in actions and "powercycle" not in actions %}disabled{% endif %}>{% bs_icon 'three-dots' %}</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow">
|
||||
{% if "suspend" in actions %}
|
||||
<li><form action="{% url 'instances:suspend' instance.id %}" method="post">{% csrf_token %}
|
||||
<button type="submit" class="dropdown-item">{% bs_icon 'pause-fill' %} {% trans "Suspend" %}</button>
|
||||
</form></li>
|
||||
{% endif %}
|
||||
{% if "force_off" in actions %}
|
||||
<li><form action="{% url 'instances:force_off' instance.id %}" method="post">{% csrf_token %}
|
||||
<button type="submit" class="dropdown-item text-danger"
|
||||
data-confirm="{% blocktrans with name=instance.name %}Force off {{ name }}? It stops at once, as after a power cut; what the guest has not written to disk is lost.{% endblocktrans %}"
|
||||
onclick="return confirm(this.dataset.confirm)">{% bs_icon 'power' %} {% trans "Force off" %}</button>
|
||||
</form></li>
|
||||
{% endif %}
|
||||
{% if "powercycle" in actions %}
|
||||
<li><form action="{% url 'instances:powercycle' instance.id %}" method="post">{% csrf_token %}
|
||||
<button type="submit" class="dropdown-item text-danger"
|
||||
data-confirm="{% blocktrans with name=instance.name %}Power cycle {{ name }}? It stops at once, as after a power cut, and starts again; what the guest has not written to disk is lost.{% endblocktrans %}"
|
||||
onclick="return confirm(this.dataset.confirm)">{% bs_icon 'repeat' %} {% trans "Power cycle" %}</button>
|
||||
</form></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
|
|||
17
instances/templates/instance_name_cell.html
Normal file
17
instances/templates/instance_name_cell.html
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{% load i18n %}
|
||||
{% load bootstrap_icons %}
|
||||
{# a VM's name, its title, and with owners its owners: the first as a badge, the others counted #}
|
||||
<a class="fw-semibold text-break" href="{% url 'instances:instance' instance.id %}">{{ instance.name }}</a>
|
||||
{% if instance.info and instance.title %}<div class="small text-body-secondary text-break">{{ instance.title }}</div>{% endif %}
|
||||
{% if owners %}
|
||||
{% with users=instance.userinstance_set.all %}
|
||||
{% if users %}
|
||||
<div class="mt-1">
|
||||
<span class="badge text-bg-light border">{% bs_icon 'person' %} {{ users.0.user }}</span>
|
||||
{% if users|length > 1 %}
|
||||
<span class="badge text-bg-light border" title="{% for owner in users|slice:"1:" %}{{ owner.user }}{% if not forloop.last %}, {% endif %}{% endfor %}">+{{ users|length|add:"-1" }}<span class="visually-hidden"> {% trans "more owners" %}: {% for owner in users|slice:"1:" %}{{ owner.user }}{% if not forloop.last %}, {% endif %}{% endfor %}</span></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
{% load vm_states %}
|
||||
{% if instance.info %}{% vm_state instance.info.0 as state %}{% else %}{% vm_state None as state %}{% endif %}
|
||||
<span class="text-{{ state.1 }}" data-state="{{ state.0 }}">{{ state.0 }}</span>
|
||||
<span class="badge text-bg-{{ state.1 }}" data-state="{{ state.0 }}">{{ state.0 }}</span>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from django import template
|
||||
|
||||
from instances.states import allowed_actions, state_label
|
||||
from instances.states import allowed_actions, manages, state_label
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
|
@ -17,3 +17,9 @@ def vm_actions(context, instance):
|
|||
if not instance.info:
|
||||
return set()
|
||||
return allowed_actions(context["request"].user, instance, instance.info[0])
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def vm_manages(context, instance):
|
||||
"""{% vm_manages instance as manages %}: the user is a superuser or one of the VM's owners."""
|
||||
return manages(context["request"].user, instance)
|
||||
|
|
|
|||
|
|
@ -18,13 +18,13 @@ function plainRow(text) {
|
|||
function group(rows, host) {
|
||||
return {dataset: host ? {host} : {}, querySelectorAll: (s) => (s === "tr" ? rows : [])};
|
||||
}
|
||||
function page(groups, text, state, noMatch) {
|
||||
function page(groups, text, state, noMatch, extra = {}) {
|
||||
// a list without a state list (state undefined) has no such element
|
||||
const inputs = {filter: {value: text}, "state-filter": state === undefined ? undefined : {value: state},
|
||||
"no-match": noMatch};
|
||||
"no-match": noMatch, "filter-status": extra.status};
|
||||
return {
|
||||
getElementById: (id) => inputs[id],
|
||||
querySelectorAll: (s) => (s === ".searchable" ? groups : []),
|
||||
querySelectorAll: (s) => (s === ".searchable" ? groups : s === "[data-count-state]" ? extra.counts || [] : []),
|
||||
};
|
||||
}
|
||||
const visible = (rows) => rows.filter((r) => !r.hidden).map((r) => r.textContent);
|
||||
|
|
@ -79,3 +79,18 @@ filterInstances(page([group(menus)], "nothing-like-this", "", noMatch));
|
|||
assert.strictEqual(noMatch.hidden, false, "no row: the message shows");
|
||||
filterInstances(page([group(menus)], "web", "", noMatch));
|
||||
assert.strictEqual(noMatch.hidden, true, "a row: the message goes");
|
||||
|
||||
// the state pills count the VMs the search finds, each state's and all; a
|
||||
// state without any is hidden unless chosen; the status says how many show
|
||||
const count = (state) => ({dataset: {countState: state}, textContent: "", parentElement: {hidden: false}});
|
||||
const counts = [count(""), count("Active"), count("Off"), count("Crashed")];
|
||||
const status = {dataset: {text: "%(shown)s of %(all)s VMs match"}, textContent: ""};
|
||||
const fleet = [vm("web-1", "Active", "web-1"), vm("web-2", "Off", "web-2"), vm("db-1", "Active", "db-1")];
|
||||
filterInstances(page([group(fleet)], "web", "Off", undefined, {counts, status}));
|
||||
assert.deepStrictEqual(counts.map((c) => c.textContent), [2, 1, 1, 0], "counts follow the search, not the state");
|
||||
assert.deepStrictEqual(counts.map((c) => c.parentElement.hidden), [false, false, false, true]);
|
||||
assert.strictEqual(status.textContent, "1 of 3 VMs match");
|
||||
filterInstances(page([group(fleet)], "", "Crashed", undefined, {counts, status}));
|
||||
assert.strictEqual(counts[3].parentElement.hidden, false, "the chosen state stays, even empty");
|
||||
filterInstances(page([group(fleet)], "no-such-vm", "Active", undefined, {counts, status}));
|
||||
assert.strictEqual(counts[0].parentElement.hidden, false, "All stays when nothing is found");
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ class UnreachableListTests(TestCase):
|
|||
AppSettings.objects.filter(key="VIEW_INSTANCES_LIST_STYLE").update(value="grouped")
|
||||
response = self.get(reverse("instances:index"), unreachable)
|
||||
self.assert_unknown(response, [self.vm_a, self.vm_b])
|
||||
self.assertContains(response, "Not Connected")
|
||||
self.assertContains(response, "Not connected")
|
||||
|
||||
def test_one_unreadable_vm_does_not_hide_the_others(self):
|
||||
AppSettings.objects.filter(key="VIEW_INSTANCES_LIST_STYLE").update(value="nongrouped")
|
||||
|
|
|
|||
|
|
@ -123,8 +123,9 @@ class StateTests(TestCase):
|
|||
# static/js/table_filter.js filters by these (test_table_filter_js)
|
||||
AppSettings.objects.filter(key="VIEW_INSTANCES_LIST_STYLE").update(value="grouped")
|
||||
page = html.fromstring(self.get(self.admin, reverse("instances:index")))
|
||||
options = page.xpath("//select[@id='state-filter']/option/text()")
|
||||
self.assertEqual(options, ["All states", *LABELS.values(), "Unknown"])
|
||||
# a pill for all and for each state, every state kept (table_filter.js hides the empty ones)
|
||||
pills = page.xpath("//div[@id='state-pills']/button/@data-pill-state")
|
||||
self.assertEqual(pills, ["", *LABELS.values(), "Unknown"])
|
||||
marks = page.xpath("//tbody[@data-host='st']//tr//@data-state")
|
||||
self.assertEqual(marks, list(LABELS.values()))
|
||||
# the host row stays on top of its VMs; memory and vCPUs sort as numbers
|
||||
|
|
@ -133,6 +134,43 @@ class StateTests(TestCase):
|
|||
self.assertEqual([r.xpath("td[@data-sort]/@data-sort") for r in rows[1:2]], [["2", "2097152"]])
|
||||
|
||||
|
||||
def row_group(self, page, state):
|
||||
url = reverse("instances:instance", args=[self.vms[state].id])
|
||||
return page.xpath(f"//tr[.//a[@href='{url}']]//div[@role='group']")
|
||||
|
||||
@staticmethod
|
||||
def enabled(group):
|
||||
return [c.get("aria-label") for c in group.xpath(".//*[contains(@class,'btn-sm')]") if c.get("disabled") is None]
|
||||
|
||||
def test_each_row_has_the_same_group_its_state_s_actions_enabled(self):
|
||||
# console, the power action, the menu, in every row: those that do not apply now disabled
|
||||
page = html.fromstring(self.get(self.admin, reverse("instances:index")))
|
||||
power = {"poweron": "Start", "resume": "Resume", "poweroff": "Shut down"}
|
||||
in_menu = {"suspend": "Suspend", "force_off": "Force off", "powercycle": "Power cycle"}
|
||||
for state, label in LABELS.items():
|
||||
vm = self.vms[state]
|
||||
with self.subTest(state=label):
|
||||
(group,) = self.row_group(page, state)
|
||||
self.assertEqual(group.get("aria-label"), f"Actions for {vm.name}")
|
||||
self.assertEqual(len(group.xpath(".//*[contains(@class,'btn-sm')]")), 3)
|
||||
running = state in (1, 2)
|
||||
expected = (["Console"] if running else []) + [power[a] for a in power if a in ACTIONS[state]]
|
||||
if ACTIONS[state] & set(in_menu):
|
||||
expected.append("More actions")
|
||||
self.assertEqual(self.enabled(group), expected)
|
||||
items = [i.text_content().strip() for i in group.xpath(".//*[contains(@class,'dropdown-item')]")]
|
||||
self.assertEqual(items, [in_menu[a] for a in in_menu if a in ACTIONS[state]])
|
||||
|
||||
def test_an_owner_sees_the_group_disabled_on_a_vm_an_administrator_paused(self):
|
||||
page = html.fromstring(self.get(self.owner, reverse("instances:index")))
|
||||
(group,) = self.row_group(page, 3)
|
||||
self.assertEqual(self.enabled(group), [])
|
||||
self.assertTrue(group.xpath(".//button[@aria-label='Resume'][@disabled]"))
|
||||
# a user who only views VMs gets no group
|
||||
page = html.fromstring(self.get(self.viewer, reverse("instances:index")))
|
||||
self.assertEqual(len(page.xpath("//tbody//tr[@data-search]")), len(self.vms))
|
||||
self.assertEqual(page.xpath("//div[@role='group']"), [])
|
||||
|
||||
def test_rows_are_searched_for_their_vm_not_their_buttons(self):
|
||||
# table_filter.js searches data-search: a menu's "Force off" would match "off" in every row
|
||||
for style in ("grouped", "nongrouped"):
|
||||
|
|
|
|||
|
|
@ -56,6 +56,15 @@ th[aria-sort="ascending"]:not([data-sort-method="none"])::after {
|
|||
border-bottom-color: currentColor;
|
||||
}
|
||||
|
||||
/* a phone keeps Bootstrap's own cell padding: some themes (Zephyr) double
|
||||
it, and a list's last column no longer fits the screen */
|
||||
@media (max-width: 575.98px) {
|
||||
.table > :not(caption) > * > * {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.host-collapsed tr[host] {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
8
static/icon_cache/arrows-collapse_None_None_None.svg
Normal file
8
static/icon_cache/arrows-collapse_None_None_None.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrows-collapse" viewBox="0 0 16 16">
|
||||
|
||||
|
||||
<path fill-rule="evenodd" d="M1 8a.5.5 0 0 1 .5-.5h13a.5.5 0 0 1 0 1h-13A.5.5 0 0 1 1 8m7-8a.5.5 0 0 1 .5.5v3.793l1.146-1.147a.5.5 0 0 1 .708.708l-2 2a.5.5 0 0 1-.708 0l-2-2a.5.5 0 1 1 .708-.708L7.5 4.293V.5A.5.5 0 0 1 8 0m-.5 11.707-1.146 1.147a.5.5 0 0 1-.708-.708l2-2a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 11.707V15.5a.5.5 0 0 1-1 0z"/>
|
||||
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 521 B |
8
static/icon_cache/display_None_None_None.svg
Normal file
8
static/icon_cache/display_None_None_None.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-display" viewBox="0 0 16 16">
|
||||
|
||||
|
||||
<path d="M0 4s0-2 2-2h12s2 0 2 2v6s0 2-2 2h-4c0 .667.083 1.167.25 1.5H11a.5.5 0 0 1 0 1H5a.5.5 0 0 1 0-1h.75c.167-.333.25-.833.25-1.5H2s-2 0-2-2zm1.398-.855a.758.758 0 0 0-.254.302A1.46 1.46 0 0 0 1 4.01V10c0 .325.078.502.145.602.07.105.17.188.302.254a1.464 1.464 0 0 0 .538.143L2.01 11H14c.325 0 .502-.078.602-.145a.758.758 0 0 0 .254-.302 1.464 1.464 0 0 0 .143-.538L15 9.99V4c0-.325-.078-.502-.145-.602a.757.757 0 0 0-.302-.254A1.46 1.46 0 0 0 13.99 3H2c-.325 0-.502.078-.602.145z"/>
|
||||
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 652 B |
8
static/icon_cache/person_None_None_None.svg
Normal file
8
static/icon_cache/person_None_None_None.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-person" viewBox="0 0 16 16">
|
||||
|
||||
|
||||
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6m2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0m4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4m-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664z"/>
|
||||
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 412 B |
|
|
@ -7,11 +7,18 @@
|
|||
// shows all of its rows, and its header row (data-group-header) shows while
|
||||
// any of its rows does. A page's #no-match shows while no row does. The
|
||||
// search is remembered for a day, per page.
|
||||
//
|
||||
// The instance list's state pills (#state-pills) set the state; each one's
|
||||
// [data-count-state] counts the VMs of its state the search finds (its
|
||||
// state left aside), "" all of them; a state's pill with none is hidden
|
||||
// unless chosen. #filter-status tells screen readers how many VMs match,
|
||||
// collapsed hosts' VMs too.
|
||||
function filterTable(doc) {
|
||||
var text = doc.getElementById("filter").value.toLowerCase();
|
||||
var states = doc.getElementById("state-filter");
|
||||
var state = states ? states.value : "";
|
||||
var anyShown = false;
|
||||
var counts = {"": 0}, vms = 0, shownVms = 0;
|
||||
doc.querySelectorAll(".searchable").forEach(function (group) {
|
||||
var host = group.dataset.host;
|
||||
var hostMatches = host !== undefined && host.toLowerCase().includes(text);
|
||||
|
|
@ -23,9 +30,17 @@ function filterTable(doc) {
|
|||
}
|
||||
var mark = row.querySelector("[data-state]");
|
||||
var searched = row.dataset.search !== undefined ? row.dataset.search : row.textContent;
|
||||
row.hidden = !((!state || (mark && mark.dataset.state === state)) &&
|
||||
(hostMatches || searched.toLowerCase().includes(text)));
|
||||
var found = hostMatches || searched.toLowerCase().includes(text);
|
||||
row.hidden = !((!state || (mark && mark.dataset.state === state)) && found);
|
||||
shown = shown || !row.hidden;
|
||||
if (mark) {
|
||||
vms += 1;
|
||||
shownVms += row.hidden ? 0 : 1;
|
||||
if (found) {
|
||||
counts[""] += 1;
|
||||
counts[mark.dataset.state] = (counts[mark.dataset.state] || 0) + 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (header) {
|
||||
header.hidden = !shown;
|
||||
|
|
@ -36,6 +51,15 @@ function filterTable(doc) {
|
|||
if (noMatch) {
|
||||
noMatch.hidden = anyShown;
|
||||
}
|
||||
doc.querySelectorAll("[data-count-state]").forEach(function (count) {
|
||||
var n = counts[count.dataset.countState] || 0;
|
||||
count.textContent = n;
|
||||
count.parentElement.hidden = n === 0 && count.dataset.countState !== "" && count.dataset.countState !== state;
|
||||
});
|
||||
var status = doc.getElementById("filter-status");
|
||||
if (status) {
|
||||
status.textContent = status.dataset.text.replace("%(shown)s", shownVms).replace("%(all)s", vms);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
|
|
@ -53,12 +77,31 @@ if (typeof module !== "undefined") {
|
|||
filterTable(document);
|
||||
});
|
||||
}
|
||||
var pills = document.getElementById("state-pills");
|
||||
function chooseState(pill) {
|
||||
states.value = pill.dataset.pillState;
|
||||
pills.querySelectorAll("[data-pill-state]").forEach(function (other) {
|
||||
other.classList.toggle("active", other === pill);
|
||||
other.setAttribute("aria-pressed", other === pill);
|
||||
});
|
||||
filterTable(document);
|
||||
}
|
||||
if (pills) {
|
||||
pills.addEventListener("click", function (event) {
|
||||
var pill = event.target.closest("[data-pill-state]");
|
||||
if (pill) {
|
||||
chooseState(pill);
|
||||
}
|
||||
});
|
||||
}
|
||||
var clear = document.getElementById("clear-filters");
|
||||
if (clear) {
|
||||
clear.addEventListener("click", function () {
|
||||
search.value = "";
|
||||
Cookies.remove(document.title + "_filter");
|
||||
if (states) {
|
||||
if (pills) {
|
||||
chooseState(pills.querySelector('[data-pill-state=""]'));
|
||||
} else if (states) {
|
||||
states.value = "";
|
||||
}
|
||||
filterTable(document);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue