mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-07-08 08:21:14 +00:00
Sparklines and misc tweaks
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / lint-translations (push) Has been cancelled
ChangeDetection.io App Test / lint-template-i18n (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-14 (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* Queue activity sparkline — a compact "is it alive?" activity strip for the sidebar.
|
||||
*
|
||||
* UX goal: answer the user's gut question "is this thing actually doing anything?".
|
||||
* - A pinned, gently PULSING leading dot at the right edge beats like a heartbeat
|
||||
* even when totally idle, so the UI always reads as alive/monitoring — a frozen
|
||||
* dot would read as broken.
|
||||
* - Real in-flight work bumps the line, which then scrolls left across the window,
|
||||
* so you can see it really does check things over the last few minutes.
|
||||
*
|
||||
* Signal plotted = queue_size + checking_now (total in-flight work). Either being
|
||||
* non-zero lifts the trace; plain queue size alone sits at 0 while workers drain it.
|
||||
*
|
||||
* Rendering: a scrolling strip chart. The right edge is "now", the left edge is
|
||||
* "now - WINDOW_SECONDS". New samples always enter from the right; old data scrolls
|
||||
* off the left and fades out as it goes (left-edge brightness ramp).
|
||||
*
|
||||
* Hardening (lessons from common sparkline libs, e.g. fnando/sparkline #14/#24):
|
||||
* - Autoscaled 0..max with headroom + a MIN floor, so it always stays inside the
|
||||
* canvas height and never divides by zero on a flat/all-zero queue.
|
||||
* - devicePixelRatio aware (crisp on retina), re-measured on resize (the rail
|
||||
* expands 64px->190px on hover).
|
||||
* - Pauses the rAF loop when the tab is hidden.
|
||||
*
|
||||
* History survives a full page reload via localStorage: we persist the queue/checking
|
||||
* EVENT LOG (a small step-function, not pixels). On load we sample that log across the
|
||||
* visible window, so the strip immediately shows the last few minutes instead of a
|
||||
* blank screen.
|
||||
*
|
||||
* Fed by the same Socket.IO 'queue_size' / 'checking_now' events as realtime.js.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const STORE_KEY = "cdio-queue-spark-v1";
|
||||
const WINDOW_SECONDS = 180; // how much recent history the strip shows (a few minutes)
|
||||
const RETAIN_MS = WINDOW_SECONDS * 1000 * 1.5; // keep a little more than one screenful
|
||||
const MAX_EVENTS = 600; // hard cap on stored events (localStorage size guard)
|
||||
const MIN_SCALE = 1; // autoscale floor: avoids /0 and stops a 0/1 queue filling the panel
|
||||
const PULSE_MS = 1500; // leading-dot heartbeat period — liveness even when idle
|
||||
const RISE_LERP = 0.30; // autoscale rises quickly so a new bump shows promptly
|
||||
const FALL_LERP = 0.04; // ...and falls slowly so it doesn't jitter
|
||||
const reduceMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const FRAME_MS = reduceMotion ? 250 : 100; // 10fps (plenty here); slower under reduced-motion
|
||||
|
||||
// --- Shared state across all canvases (desktop rail + mobile drawer share one history) ---
|
||||
let eventLog = []; // [{t: epoch_ms, v: number}] step-function of total activity
|
||||
let currentQueue = 0;
|
||||
let currentChecking = 0;
|
||||
|
||||
function totalNow() { return currentQueue + currentChecking; }
|
||||
|
||||
function stepValueAt(t) {
|
||||
// Last event at or before t (step function). Log is small; scan from the end.
|
||||
for (let i = eventLog.length - 1; i >= 0; i--) {
|
||||
if (eventLog[i].t <= t) return eventLog[i].v;
|
||||
}
|
||||
return NaN; // before our earliest knowledge -> leave that column blank
|
||||
}
|
||||
|
||||
function pushEvent(now) {
|
||||
const v = totalNow();
|
||||
const last = eventLog[eventLog.length - 1];
|
||||
if (!last || last.v !== v) eventLog.push({ t: now, v: v });
|
||||
}
|
||||
|
||||
function trimLog(now) {
|
||||
const cutoff = now - RETAIN_MS;
|
||||
// Keep the most recent event that predates the cutoff as a baseline anchor.
|
||||
let anchor = -1;
|
||||
for (let i = 0; i < eventLog.length; i++) {
|
||||
if (eventLog[i].t <= cutoff) anchor = i; else break;
|
||||
}
|
||||
if (anchor > 0) eventLog.splice(0, anchor);
|
||||
if (eventLog.length > MAX_EVENTS) eventLog.splice(0, eventLog.length - MAX_EVENTS);
|
||||
}
|
||||
|
||||
function loadLog() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
const log = Array.isArray(parsed) ? parsed : parsed.log;
|
||||
if (!Array.isArray(log)) return [];
|
||||
eventLog = log.filter(e => e && typeof e.t === "number" && typeof e.v === "number");
|
||||
trimLog(Date.now());
|
||||
return eventLog;
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
let saveTimer = null;
|
||||
function saveLog() {
|
||||
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; }
|
||||
try {
|
||||
trimLog(Date.now());
|
||||
localStorage.setItem(STORE_KEY, JSON.stringify({ savedAt: Date.now(), log: eventLog }));
|
||||
} catch (e) { /* quota / private mode — history is best-effort */ }
|
||||
}
|
||||
function scheduleSave() {
|
||||
if (saveTimer) return;
|
||||
saveTimer = setTimeout(function () { saveTimer = null; saveLog(); }, 750);
|
||||
}
|
||||
|
||||
// --- One Spark per canvas (different widths: rail vs drawer) ---
|
||||
function Spark(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.ctx = canvas.getContext("2d");
|
||||
this.W = 1;
|
||||
this.cssW = 0;
|
||||
this.cssH = 0;
|
||||
this.displayMax = MIN_SCALE;
|
||||
this.resize();
|
||||
}
|
||||
|
||||
Spark.prototype.resize = function () {
|
||||
const cssW = Math.max(0, Math.round(this.canvas.clientWidth));
|
||||
const cssH = Math.max(0, Math.round(this.canvas.clientHeight));
|
||||
if (cssW === this.cssW && cssH === this.cssH) return;
|
||||
this.cssW = cssW;
|
||||
this.cssH = cssH;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
this.canvas.width = Math.max(1, Math.round(cssW * dpr));
|
||||
this.canvas.height = Math.max(1, Math.round(cssH * dpr));
|
||||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
this.W = Math.max(1, cssW);
|
||||
};
|
||||
|
||||
// Wall-clock time represented by pixel column x: right edge = now, left edge = now - window.
|
||||
Spark.prototype.columnTime = function (x, now) {
|
||||
return now - ((this.W - 1 - x) / Math.max(1, this.W - 1)) * (WINDOW_SECONDS * 1000);
|
||||
};
|
||||
|
||||
Spark.prototype.y = function (v) {
|
||||
const pad = 2;
|
||||
const h = Math.max(1, this.cssH - pad * 2);
|
||||
const t = Math.min(1, v / this.displayMax);
|
||||
return pad + h * (1 - t);
|
||||
};
|
||||
|
||||
Spark.prototype.render = function (now) {
|
||||
if (this.cssW < 4 || this.cssH < 4) return;
|
||||
if (this.canvas.clientWidth !== this.cssW || this.canvas.clientHeight !== this.cssH) this.resize();
|
||||
|
||||
const W = this.W;
|
||||
|
||||
// Sample the step-function across the visible window (newest at right) and
|
||||
// find the autoscale max in the same pass.
|
||||
const ys = new Array(W);
|
||||
let maxV = MIN_SCALE;
|
||||
for (let x = 0; x < W; x++) {
|
||||
const v = stepValueAt(this.columnTime(x, now));
|
||||
ys[x] = v;
|
||||
if (!isNaN(v) && v > maxV) maxV = v;
|
||||
}
|
||||
const tv = totalNow();
|
||||
if (tv > maxV) maxV = tv;
|
||||
const rate = maxV > this.displayMax ? RISE_LERP : FALL_LERP;
|
||||
this.displayMax += (maxV - this.displayMax) * rate;
|
||||
|
||||
const ctx = this.ctx;
|
||||
ctx.clearRect(0, 0, this.cssW, this.cssH);
|
||||
|
||||
// Whole trace in a SINGLE stroke; the left->right fade (old data dimming as it
|
||||
// scrolls out of view) is a gradient strokeStyle rather than one stroke per
|
||||
// pixel column — ~140 draw calls collapse to 1.
|
||||
const grad = ctx.createLinearGradient(0, 0, this.cssW, 0);
|
||||
grad.addColorStop(0, "rgba(255,255,255,0.10)");
|
||||
grad.addColorStop(1, "rgba(255,255,255,0.95)");
|
||||
ctx.strokeStyle = grad;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineCap = "round";
|
||||
ctx.beginPath();
|
||||
let pen = false;
|
||||
for (let x = 0; x < W; x++) {
|
||||
const v = ys[x];
|
||||
if (isNaN(v)) { pen = false; continue; } // gap (no history yet) breaks the line
|
||||
const py = this.y(v);
|
||||
if (pen) ctx.lineTo(x + 0.5, py);
|
||||
else { ctx.moveTo(x + 0.5, py); pen = true; }
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Pinned, gently pulsing leading dot at the right edge = the "it's alive" tell.
|
||||
const rightV = isNaN(ys[W - 1]) ? tv : ys[W - 1];
|
||||
const pulse = 0.5 + 0.5 * Math.sin(((now % PULSE_MS) / PULSE_MS) * Math.PI * 2);
|
||||
ctx.save();
|
||||
ctx.shadowColor = "rgba(255,255,255,0.9)";
|
||||
ctx.shadowBlur = 4 + 5 * pulse;
|
||||
ctx.fillStyle = "rgba(255,255,255," + (0.75 + 0.25 * pulse).toFixed(3) + ")";
|
||||
ctx.beginPath();
|
||||
ctx.arc(W - 1.5, this.y(rightV), 1.6 + 0.7 * pulse, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
// --- Animation loop ---
|
||||
let instances = [];
|
||||
let rafId = null;
|
||||
let lastFrame = 0;
|
||||
|
||||
function frame(ts) {
|
||||
rafId = requestAnimationFrame(frame);
|
||||
if (ts - lastFrame < FRAME_MS) return;
|
||||
lastFrame = ts;
|
||||
const now = Date.now();
|
||||
for (let i = 0; i < instances.length; i++) instances[i].render(now);
|
||||
}
|
||||
function start() { if (rafId === null) { lastFrame = 0; rafId = requestAnimationFrame(frame); } }
|
||||
function stop() { if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } }
|
||||
|
||||
function attach(socket) {
|
||||
if (!socket) return;
|
||||
socket.on("queue_size", function (d) {
|
||||
currentQueue = parseInt(d && d.q_length, 10) || 0;
|
||||
pushEvent(Date.now());
|
||||
scheduleSave();
|
||||
});
|
||||
socket.on("checking_now", function (d) {
|
||||
currentChecking = parseInt(d && d.count, 10) || 0;
|
||||
pushEvent(Date.now());
|
||||
scheduleSave();
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
const canvases = Array.prototype.slice.call(document.querySelectorAll("canvas.queue-spark"));
|
||||
if (!canvases.length) return;
|
||||
|
||||
loadLog();
|
||||
|
||||
// Seed current levels from the server-rendered numbers so the line starts at
|
||||
// the right height before the first socket event arrives.
|
||||
const qEl = document.querySelector(".queue-size-int");
|
||||
const cEl = document.querySelector(".checking-now-int");
|
||||
if (qEl) currentQueue = parseInt(qEl.textContent, 10) || 0;
|
||||
if (cEl) currentChecking = parseInt(cEl.textContent, 10) || 0;
|
||||
pushEvent(Date.now());
|
||||
|
||||
instances = canvases.map(function (c) { return new Spark(c); });
|
||||
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
const ro = new ResizeObserver(function () {
|
||||
for (let i = 0; i < instances.length; i++) instances[i].resize();
|
||||
});
|
||||
canvases.forEach(function (c) { ro.observe(c); });
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (document.hidden) { saveLog(); stop(); }
|
||||
else { start(); }
|
||||
});
|
||||
window.addEventListener("beforeunload", saveLog);
|
||||
|
||||
// realtime.js exposes the socket either already on window or via this event.
|
||||
if (window.cdioSocket) attach(window.cdioSocket);
|
||||
else document.addEventListener("cdio:socket-ready", function (e) { attach(e.detail && e.detail.socket); });
|
||||
|
||||
start();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
|
||||
else init();
|
||||
})();
|
||||
@@ -19,8 +19,6 @@ $action-sidebar-content-slot: 1100px;
|
||||
width: $action-sidebar-width-expanded; // reserve enough to host the expanded inner block
|
||||
z-index: 60;
|
||||
pointer-events: none; // empty space doesn't intercept clicks/hovers
|
||||
|
||||
|
||||
@media only screen and (max-width: $desktop-wide-breakpoint) {
|
||||
// Hidden on mobile: the mobile drawer (hamburger) carries these items
|
||||
display: none;
|
||||
@@ -72,6 +70,24 @@ $action-sidebar-content-slot: 1100px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: relative; // anchor for rollout label
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
// Live "is it alive?" activity monitor — compact scrolling strip under the queue count.
|
||||
// New data enters from the right (pulsing dot); old data scrolls left and fades out.
|
||||
// Drawn by static/js/queue-sparkline.js. Full rail width, deliberately short.
|
||||
.action-sidebar-li--spark {
|
||||
padding: 2px 4px 4px;
|
||||
|
||||
.queue-spark {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 15px;
|
||||
border-radius: 3px;
|
||||
// Faint "screen" so the trace reads as a small instrument even when idle.
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.action-sidebar-divider {
|
||||
|
||||
@@ -165,7 +165,7 @@ $watch-table-mobile-max: 767px;
|
||||
}
|
||||
}
|
||||
.pure-table td {
|
||||
padding: 3px !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +179,9 @@ $watch-table-mobile-max: 767px;
|
||||
@media (min-width: 1600px) {
|
||||
.watch-table {
|
||||
td.last-checked, td.last-changed {
|
||||
white-space: nowrap;
|
||||
.innertext {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ body.has-queue {
|
||||
|
||||
td,
|
||||
th {
|
||||
padding: $table-cell-padding;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@@ -62,9 +61,16 @@ body.has-queue {
|
||||
onto its own row, centered beneath the three columns above it.
|
||||
*/
|
||||
td.inline.title-col {
|
||||
width: 100%;
|
||||
.grid-wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
/* Only 3 explicit columns. The optional restock column is placed into an
|
||||
IMPLICIT 4th track (grid-column: 4 below), which is created only when
|
||||
.restock-info-wrap is actually present. Reserving an explicit 4th track
|
||||
here would leave a phantom 0.8rem gap-gutter after .status-icons on the
|
||||
(common) watches that have no restock info. */
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
grid-auto-columns: auto;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -44,6 +44,7 @@
|
||||
{% if socket_io_enabled %}
|
||||
<script src="{{url_for('static_content', group='js', filename='socket.io.min.js')}}"></script>
|
||||
<script src="{{url_for('static_content', group='js', filename='realtime.js')}}" defer></script>
|
||||
<script src="{{url_for('static_content', group='js', filename='queue-sparkline.js')}}" defer></script>
|
||||
{% endif %}
|
||||
{%- set _html_head_extras = get_html_head_extras() -%}
|
||||
{%- if _html_head_extras %}
|
||||
|
||||
@@ -11,6 +11,31 @@
|
||||
<li class="pure-menu-item " id="menu-mute">
|
||||
<a href="{{ url_for('settings.toggle_all_muted') }}" ><img src="{{url_for('static_content', group='images', filename='bell-off.svg')}}" alt="{% if all_muted %}{{ _('Unmute notifications') }}{% else %}{{ _('Mute notifications') }}{% endif %}" title="{% if all_muted %}{{ _('Notifications are muted - click to unmute') }}{% else %}{{ _('Mute notifications') }}{% endif %}" class="icon icon-mute"{% if not all_muted %} style="opacity: 0.3"{% endif %}></a>
|
||||
</li>
|
||||
|
||||
<li class="pure-menu-item " >
|
||||
<a href="{{ url_for('settings.settings_page') }}"
|
||||
|
||||
title="{{ _('Settings') }}">
|
||||
<svg class="action-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.01a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h.01a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v.01a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
<span class="action-label">{{ _('Settings') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="pure-menu-item " >
|
||||
<a href="{{ url_for('imports.import_page') }}"
|
||||
|
||||
title="{{ _('Import') }}">
|
||||
<svg class="action-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 9 12 4 17 9"/>
|
||||
<line x1="12" y1="4" x2="12" y2="16"/>
|
||||
</svg>
|
||||
<span class="action-label">{{ _('Import') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="pure-menu-item menu-collapsible">
|
||||
<a href="{{ url_for('ui.ui_edit.edit_page', uuid=uuid, next='diff') }}"
|
||||
|
||||
@@ -95,39 +95,17 @@
|
||||
|
||||
<li class="action-sidebar-divider" aria-hidden="true"></li>
|
||||
|
||||
<li class="action-sidebar-li">
|
||||
<a href="{{ url_for('settings.settings_page') }}"
|
||||
class="action-sidebar-item {% if ep.startswith('settings.') %}active{% endif %}"
|
||||
title="{{ _('Settings') }}">
|
||||
<svg class="action-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.01a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h.01a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v.01a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
<span class="action-label">{{ _('Settings') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li class="action-sidebar-li">
|
||||
<a href="{{ url_for('imports.import_page') }}"
|
||||
class="action-sidebar-item {% if ep.startswith('imports.') %}active{% endif %}"
|
||||
title="{{ _('Import') }}">
|
||||
<svg class="action-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 9 12 4 17 9"/>
|
||||
<line x1="12" y1="4" x2="12" y2="16"/>
|
||||
</svg>
|
||||
<span class="action-label">{{ _('Import') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="action-sidebar-divider" aria-hidden="true"></li>
|
||||
|
||||
|
||||
<li class="action-sidebar-li" id="checking-now-stats-sidebar">
|
||||
{{ _('Checking now') }}: <span class="checking-now-int">{{ checking_now_size }}</span>
|
||||
</li>
|
||||
<li class="action-sidebar-li" id="queue-stats-sidebar">
|
||||
{{ _('In queue') }}: <span class="queue-size-int">{{ queue_size }}</span>
|
||||
</li>
|
||||
{% if socket_io_enabled %}
|
||||
<li class="action-sidebar-li action-sidebar-li--spark" aria-hidden="true">
|
||||
<canvas class="queue-spark" title="{{ _('Live activity') }}"></canvas>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="action-sidebar-li">
|
||||
{% if hosted_sticky %}
|
||||
<div id="hosted-sticky">
|
||||
|
||||
Reference in New Issue
Block a user