devpi-admin 1.1.1__tar.gz → 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. devpi_admin-1.2.0/.github/workflows/publish.yml +39 -0
  2. devpi_admin-1.2.0/.github/workflows/tests.yml +32 -0
  3. {devpi_admin-1.1.1 → devpi_admin-1.2.0}/.gitignore +1 -1
  4. devpi_admin-1.2.0/INSTALL.textile +190 -0
  5. {devpi_admin-1.1.1 → devpi_admin-1.2.0}/PKG-INFO +8 -5
  6. {devpi_admin-1.1.1 → devpi_admin-1.2.0}/README.md +3 -1
  7. {devpi_admin-1.1.1/src → devpi_admin-1.2.0}/devpi_admin/_version.py +3 -3
  8. {devpi_admin-1.1.1/src → devpi_admin-1.2.0}/devpi_admin/main.py +16 -0
  9. {devpi_admin-1.1.1/src → devpi_admin-1.2.0}/devpi_admin/static/css/style.css +44 -3
  10. devpi_admin-1.2.0/devpi_admin/static/favicon.svg +10 -0
  11. {devpi_admin-1.1.1/src → devpi_admin-1.2.0}/devpi_admin/static/index.html +1 -0
  12. {devpi_admin-1.1.1/src → devpi_admin-1.2.0}/devpi_admin/static/js/app.js +93 -8
  13. devpi_admin-1.2.0/devpi_admin.egg-info/PKG-INFO +231 -0
  14. devpi_admin-1.2.0/devpi_admin.egg-info/SOURCES.txt +31 -0
  15. devpi_admin-1.2.0/devpi_admin.egg-info/dependency_links.txt +1 -0
  16. devpi_admin-1.2.0/devpi_admin.egg-info/entry_points.txt +2 -0
  17. devpi_admin-1.2.0/devpi_admin.egg-info/requires.txt +1 -0
  18. devpi_admin-1.2.0/devpi_admin.egg-info/top_level.txt +1 -0
  19. {devpi_admin-1.1.1 → devpi_admin-1.2.0}/pyproject.toml +7 -19
  20. devpi_admin-1.2.0/setup.cfg +4 -0
  21. {devpi_admin-1.1.1 → devpi_admin-1.2.0}/LICENSE +0 -0
  22. {devpi_admin-1.1.1/src → devpi_admin-1.2.0}/devpi_admin/__init__.py +0 -0
  23. {devpi_admin-1.1.1/src → devpi_admin-1.2.0}/devpi_admin/static/js/api.js +0 -0
  24. {devpi_admin-1.1.1/src → devpi_admin-1.2.0}/devpi_admin/static/js/marked.min.js +0 -0
  25. {devpi_admin-1.1.1/src → devpi_admin-1.2.0}/devpi_admin/static/js/theme.js +0 -0
  26. {devpi_admin-1.1.1 → devpi_admin-1.2.0}/tests/__init__.py +0 -0
  27. {devpi_admin-1.1.1 → devpi_admin-1.2.0}/tests/test_cached_versions.py +0 -0
  28. {devpi_admin-1.1.1 → devpi_admin-1.2.0}/tests/test_helpers.py +0 -0
  29. {devpi_admin-1.1.1 → devpi_admin-1.2.0}/tests/test_hooks.py +0 -0
  30. {devpi_admin-1.1.1 → devpi_admin-1.2.0}/tests/test_json_safe.py +0 -0
  31. {devpi_admin-1.1.1 → devpi_admin-1.2.0}/tests/test_package.py +0 -0
  32. {devpi_admin-1.1.1 → devpi_admin-1.2.0}/tests/test_tween.py +0 -0
  33. {devpi_admin-1.1.1 → devpi_admin-1.2.0}/tests/test_wants_html.py +0 -0
@@ -0,0 +1,39 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ environment: pypi
11
+ permissions:
12
+ id-token: write
13
+
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ with:
17
+ fetch-depth: 0 # needed for hatch-vcs to derive version from tag
18
+
19
+ - name: Set up Python
20
+ uses: actions/setup-python@v5
21
+ with:
22
+ python-version: "3.14"
23
+
24
+ - name: Install build dependencies
25
+ run: pip install build
26
+
27
+ - name: Install package
28
+ run: pip install .
29
+
30
+ - name: Run unit tests
31
+ run: python -m unittest discover -v tests/
32
+ env:
33
+ PYTHONWARNINGS: "ignore::UserWarning"
34
+
35
+ - name: Build package
36
+ run: python -m build
37
+
38
+ - name: Publish to PyPI
39
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,32 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test-ubuntu:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.10", "3.14"]
15
+
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ with:
19
+ fetch-depth: 0 # needed for hatch-vcs to read git tags
20
+
21
+ - name: Set up Python ${{ matrix.python-version }}
22
+ uses: actions/setup-python@v5
23
+ with:
24
+ python-version: ${{ matrix.python-version }}
25
+
26
+ - name: Install package
27
+ run: pip install .
28
+
29
+ - name: Run unit tests
30
+ run: python -m unittest discover -v tests/
31
+ env:
32
+ PYTHONWARNINGS: "ignore::UserWarning"
@@ -10,6 +10,6 @@ build
10
10
  !.gitignore
11
11
  !.github
12
12
  !.gitea
13
- src/devpi_admin/_version.py
13
+ devpi_admin/_version.py
14
14
  *.md
15
15
  !README*.md
@@ -0,0 +1,190 @@
1
+ h1. devpi-admin — inštalačný návod
2
+
3
+ h2. Predpoklady
4
+
5
+ * Python 3.9+
6
+ * Systémový používateľ vyhradený pre službu (ďalej @pypi@)
7
+ * Adresárová štruktúra podľa konvencie — príklady nižšie používajú @/opt/pypi@ a @/var/lib/pypi@, cesty je možné upraviť podľa potreby
8
+
9
+ h2. 1. Adresárová štruktúra
10
+
11
+ <pre>
12
+ /opt/pypi/venv/ — Python virtualenv
13
+ /var/lib/pypi/data/ — dáta devpi servera (databáza, stiahnuté balíky)
14
+ /opt/pypi/etc/ — konfiguračné súbory
15
+ </pre>
16
+
17
+ h2. 2. Vytvorenie virtualenv a inštalácia
18
+
19
+ <pre>
20
+ python3 -m venv /opt/pypi/venv
21
+ /opt/pypi/venv/bin/pip install --upgrade pip
22
+ /opt/pypi/venv/bin/pip install devpi-server devpi-admin
23
+ </pre>
24
+
25
+ h2. 3. Inicializácia serverových dát
26
+
27
+ Inicializácia sa vykoná *raz* pred prvým spustením:
28
+
29
+ <pre>
30
+ sudo -u pypi /opt/pypi/venv/bin/devpi-server \
31
+ --serverdir /var/lib/pypi/data \
32
+ --init
33
+ </pre>
34
+
35
+ h2. 4. Generovanie secret súboru
36
+
37
+ Devpi podpisuje autentifikačné tokeny tajným kľúčom. Bez tohto súboru sa pri každom reštarte vygeneruje nový náhodný kľúč a *všetky existujúce tokeny sa zneplatnia* — používatelia sa musia znova prihlásiť.
38
+
39
+ Secret súbor sa vytvorí *raz*:
40
+
41
+ <pre>
42
+ sudo -u pypi /opt/pypi/venv/bin/devpi-gen-secret --secretfile /opt/pypi/etc/devpi-secret
43
+ chmod 600 /opt/pypi/etc/devpi-secret
44
+ </pre>
45
+
46
+ *Pozor:* adresár @/opt/pypi/etc/@ nesmie byť group-writable, inak devpi odmietne štart.
47
+ Skontrolovať: @ls -ld /opt/pypi/etc/@ — permissions musia byť @drwxr-x---@ alebo prísnejšie.
48
+
49
+ h2. 5. Konfigurácia logovania
50
+
51
+ Devpi štandardne pridáva do logov vlastné timestampy, čo je pri journald zbytočné.
52
+ Vytvorí sa konfiguračný súbor @/opt/pypi/etc/devpi-logging.json@:
53
+
54
+ <pre>
55
+ {
56
+ "version": 1,
57
+ "disable_existing_loggers": false,
58
+ "formatters": {
59
+ "simple": {
60
+ "format": "%(levelname)-5s %(name)s %(message)s"
61
+ }
62
+ },
63
+ "handlers": {
64
+ "console": {
65
+ "class": "logging.StreamHandler",
66
+ "stream": "ext://sys.stdout",
67
+ "formatter": "simple"
68
+ }
69
+ },
70
+ "root": {
71
+ "level": "INFO",
72
+ "handlers": ["console"]
73
+ }
74
+ }
75
+ </pre>
76
+
77
+ h2. 6. systemd service unit
78
+
79
+ Uložiť ako @/etc/systemd/system/devpi.service@:
80
+
81
+ <pre>
82
+ [Unit]
83
+ Description=devpi package server
84
+ After=network.target
85
+
86
+ [Service]
87
+ User=pypi
88
+ Group=pypi
89
+ ExecStart=/opt/pypi/venv/bin/devpi-server \
90
+ --serverdir /var/lib/pypi/data \
91
+ --secretfile /opt/pypi/etc/devpi-secret \
92
+ --logger-cfg /opt/pypi/etc/devpi-logging.json \
93
+ --host 0.0.0.0 \
94
+ --port 3141 \
95
+ --outside-url https://pypi.villapro.eu \
96
+ --restrict-modify root
97
+ Restart=on-failure
98
+ RestartSec=5
99
+ StandardOutput=journal
100
+ StandardError=journal
101
+
102
+ [Install]
103
+ WantedBy=multi-user.target
104
+ </pre>
105
+
106
+ *@--outside-url@* — verejná URL servera (ak beží za reverse proxy). Ak nie je reverse proxy, je možné túto voľbu vynechať alebo nastaviť na @http://server-ip:3141@.
107
+
108
+ *@--restrict-modify root@* — zakazuje bežným používateľom vytvárať nových používateľov a indexy. Odporúčané pre produkciu.
109
+
110
+ Aktivácia a spustenie:
111
+
112
+ <pre>
113
+ systemctl daemon-reload
114
+ systemctl enable devpi
115
+ systemctl start devpi
116
+ systemctl status devpi
117
+ </pre>
118
+
119
+ h2. 7. Overenie inštalácie
120
+
121
+ Po spustení je server dostupný na @http://localhost:3141@.
122
+ Webové rozhranie: @http://localhost:3141/+admin/@
123
+
124
+ Na overenie, že plugin devpi-admin je správne načítaný:
125
+
126
+ <pre>
127
+ curl -s http://localhost:3141/+api | python3 -m json.tool | grep devpi-admin
128
+ </pre>
129
+
130
+ Výstup by mal obsahovať @"devpi-admin"@ v zozname features.
131
+
132
+ h2. 8. Prvé prihlásenie a nastavenie
133
+
134
+ h3. 8.1 Prihlásenie
135
+
136
+ Webové rozhranie sa otvorí v prehliadači. Prihlásenie cez tlačidlo *Login* (vpravo hore).
137
+
138
+ Predvolené prihlasovacie údaje:
139
+ * *Používateľ:* @root@
140
+ * *Heslo:* _(prázdne — pri prvom prihlásení stačí stlačiť Login bez hesla)_
141
+
142
+ *Ihneď po prihlásení je potrebné nastaviť heslo pre root:* kliknutím na meno @root@ v pravom hornom rohu sa otvorí dialóg na zmenu hesla.
143
+
144
+ h3. 8.2 Mirror index (PyPI)
145
+
146
+ devpi pri inicializácii automaticky vytvorí používateľa @root@ a index @root/pypi@ (mirror PyPI). Tento index je pripravený na použitie bez ďalšej konfigurácie.
147
+
148
+ V záložke *Indexes* by mal byť viditeľný @root/pypi@ typu @mirror@.
149
+
150
+ h3. 8.3 Vytvorenie privátneho indexu
151
+
152
+ Pre nahrávanie vlastných balíkov:
153
+
154
+ # Vytvorenie používateľa pre tím: *Users → + New User*
155
+ # Vytvorenie indexu: *Indexes → + New Index*
156
+ ** *Owner:* daný používateľ
157
+ ** *Type:* @stage@
158
+ ** *Bases:* @root/pypi@ (balíky ktoré nie sú v indexe sa stiahnu z PyPI)
159
+ ** *Volatile:* podľa potreby (volatile = možnosť prepísať rovnakú verziu)
160
+
161
+ h3. 8.4 Konfigurácia pip na klientoch
162
+
163
+ <pre>
164
+ # /etc/pip.conf alebo ~/.pip/pip.conf
165
+ [global]
166
+ index-url = https://pypi.villapro.eu/user/index/+simple/
167
+ </pre>
168
+
169
+ Pre autentifikovaný prístup:
170
+
171
+ <pre>
172
+ [global]
173
+ index-url = https://user:heslo@pypi.villapro.eu/user/index/+simple/
174
+ </pre>
175
+
176
+ h2. 9. Aktualizácia devpi-admin
177
+
178
+ <pre>
179
+ systemctl stop devpi
180
+ /opt/pypi/venv/bin/pip install --upgrade devpi-admin
181
+ systemctl start devpi
182
+ </pre>
183
+
184
+ Dáta servera (@/var/lib/pypi/data@) sa aktualizáciou pluginu nedotknú.
185
+
186
+ h2. 10. Logy
187
+
188
+ <pre>
189
+ journalctl -u devpi -f
190
+ </pre>
@@ -1,11 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devpi-admin
3
- Version: 1.1.1
3
+ Version: 1.2.0
4
4
  Summary: Modern web UI plugin for devpi-server — drop-in replacement for devpi-web
5
5
  Author-email: Pavel Revak <pavelrevak@gmail.com>
6
6
  License: MIT
7
- License-File: LICENSE
8
- Keywords: admin,devpi,pypi,ui,web
7
+ Keywords: devpi,pypi,admin,web,ui
9
8
  Classifier: Development Status :: 4 - Beta
10
9
  Classifier: Framework :: Pyramid
11
10
  Classifier: Intended Audience :: Developers
@@ -16,8 +15,10 @@ Classifier: Programming Language :: Python :: 3
16
15
  Classifier: Topic :: Software Development :: Libraries
17
16
  Classifier: Topic :: System :: Software Distribution
18
17
  Requires-Python: >=3.9
19
- Requires-Dist: devpi-server>=6.0
20
18
  Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: devpi-server>=6.0
21
+ Dynamic: license-file
21
22
 
22
23
  # devpi-admin
23
24
 
@@ -35,6 +36,8 @@ talks to the standard devpi JSON API directly.
35
36
  - Server info with version of devpi-server and all installed plugins (auto-detected)
36
37
  - Cache metrics with hit-rate bars (storage, changelog, relpath caches)
37
38
  - Whoosh search index queue status
39
+ - **Replica status** (master only) — per-replica cards with online/offline badge, serial lag,
40
+ and last-seen timestamp; visible only when replicas are connected
38
41
 
39
42
  ### Indexes
40
43
  - Visual cards color-coded by type: green (stage), amber (volatile stage), blue (mirror)
@@ -51,7 +54,7 @@ talks to the standard devpi JSON API directly.
51
54
 
52
55
  ### Packages
53
56
  - Client-side search with PEP 503 name normalization
54
- - Mirror indexes: shows only cached packages (fast filesystem scan, no 17 MB index download);
57
+ - Mirror indexes: shows only cached packages (filesystem scan, no 17 MB index download);
55
58
  "Download full index" button available for complete browse
56
59
  - Package cards with latest version and `pip install` command
57
60
 
@@ -14,6 +14,8 @@ talks to the standard devpi JSON API directly.
14
14
  - Server info with version of devpi-server and all installed plugins (auto-detected)
15
15
  - Cache metrics with hit-rate bars (storage, changelog, relpath caches)
16
16
  - Whoosh search index queue status
17
+ - **Replica status** (master only) — per-replica cards with online/offline badge, serial lag,
18
+ and last-seen timestamp; visible only when replicas are connected
17
19
 
18
20
  ### Indexes
19
21
  - Visual cards color-coded by type: green (stage), amber (volatile stage), blue (mirror)
@@ -30,7 +32,7 @@ talks to the standard devpi JSON API directly.
30
32
 
31
33
  ### Packages
32
34
  - Client-side search with PEP 503 name normalization
33
- - Mirror indexes: shows only cached packages (fast filesystem scan, no 17 MB index download);
35
+ - Mirror indexes: shows only cached packages (filesystem scan, no 17 MB index download);
34
36
  "Download full index" button available for complete browse
35
37
  - Package cards with latest version and `pip install` command
36
38
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.1.1'
22
- __version_tuple__ = version_tuple = (1, 1, 1)
21
+ __version__ = version = '1.2.0'
22
+ __version_tuple__ = version_tuple = (1, 2, 0)
23
23
 
24
- __commit_id__ = commit_id = None
24
+ __commit_id__ = commit_id = 'g34be8ff92'
@@ -44,6 +44,14 @@ def devpiserver_pyramid_configure(config, pyramid_config):
44
44
  lambda request: HTTPFound("/+admin/"),
45
45
  route_name="devpi_admin_spa_noslash")
46
46
 
47
+ # Session validity check.
48
+ pyramid_config.add_route(
49
+ "devpi_admin_session",
50
+ "/+admin-api/session")
51
+ pyramid_config.add_view(
52
+ _session_view, route_name="devpi_admin_session",
53
+ request_method="GET")
54
+
47
55
  # Cached packages API for mirror indexes.
48
56
  pyramid_config.add_route(
49
57
  "devpi_admin_cached",
@@ -82,6 +90,14 @@ def _serve_index(request):
82
90
  content_type="text/html")
83
91
 
84
92
 
93
+ def _session_view(request):
94
+ """Return whether the current request carries a valid authenticated session."""
95
+ user = request.authenticated_userid
96
+ if user:
97
+ return _json_response({"valid": True, "user": user})
98
+ raise HTTPForbidden(json_body={"valid": False, "error": "not authenticated"})
99
+
100
+
85
101
  def _get_stage_or_404(xom, user, index):
86
102
  """Return stage object or raise HTTPNotFound."""
87
103
  stage = xom.model.getstage(user, index)
@@ -196,14 +196,18 @@ body {
196
196
  .user-btn-name {
197
197
  padding: 4px 10px;
198
198
  font-weight: 600;
199
- color: var(--text);
199
+ color: #22c55e;
200
200
  background: var(--bg);
201
201
  cursor: pointer;
202
202
  }
203
203
 
204
204
  .user-btn-name:hover {
205
205
  background: var(--bg-alt);
206
- color: var(--accent);
206
+ opacity: 0.8;
207
+ }
208
+
209
+ .user-btn.is-root .user-btn-name {
210
+ color: #f59e0b;
207
211
  }
208
212
 
209
213
  .user-btn-sep {
@@ -366,7 +370,19 @@ body {
366
370
  /* --- User cards --- */
367
371
 
368
372
  .user-card {
369
- border-left-color: var(--text-faint);
373
+ border-left-color: #22c55e;
374
+ }
375
+
376
+ .user-card.user-root {
377
+ border-left-color: #f59e0b;
378
+ }
379
+
380
+ .user-card .index-card-name {
381
+ color: #22c55e;
382
+ }
383
+
384
+ .user-card.user-root .index-card-name {
385
+ color: #f59e0b;
370
386
  }
371
387
 
372
388
  .user-card .kebab-menu {
@@ -1520,6 +1536,31 @@ body {
1520
1536
  margin-bottom: 4px;
1521
1537
  }
1522
1538
 
1539
+ .replica-title {
1540
+ display: flex;
1541
+ justify-content: space-between;
1542
+ align-items: center;
1543
+ }
1544
+
1545
+ .replica-badge {
1546
+ font-size: 11px;
1547
+ font-weight: 600;
1548
+ padding: 2px 7px;
1549
+ border-radius: 10px;
1550
+ text-transform: uppercase;
1551
+ letter-spacing: 0.04em;
1552
+ }
1553
+
1554
+ .replica-online {
1555
+ background: color-mix(in srgb, var(--success) 15%, transparent);
1556
+ color: var(--success);
1557
+ }
1558
+
1559
+ .replica-offline {
1560
+ background: color-mix(in srgb, var(--danger) 15%, transparent);
1561
+ color: var(--danger);
1562
+ }
1563
+
1523
1564
  .status-row {
1524
1565
  display: flex;
1525
1566
  justify-content: space-between;
@@ -0,0 +1,10 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2
+ <!-- Box bottom face -->
3
+ <polygon points="16,18 2,11 16,4 30,11" fill="#4a9eff" opacity="0.85"/>
4
+ <!-- Box left face -->
5
+ <polygon points="2,11 2,23 16,30 16,18" fill="#2563eb"/>
6
+ <!-- Box right face -->
7
+ <polygon points="30,11 30,23 16,30 16,18" fill="#1d4ed8"/>
8
+ <!-- Highlight stripe on top face -->
9
+ <polygon points="16,6 26,10.5 16,15 6,10.5" fill="#93c5fd" opacity="0.4"/>
10
+ </svg>
@@ -4,6 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>devpi admin</title>
7
+ <link rel="icon" type="image/svg+xml" href="favicon.svg">
7
8
  <link rel="stylesheet" href="css/style.css">
8
9
  </head>
9
10
  <body>
@@ -463,6 +463,7 @@
463
463
  logoutBtn.appendChild(el('span', {className: 'user-btn-action', textContent: 'Logout'}));
464
464
  loginBtn.hidden = true;
465
465
  logoutBtn.hidden = false;
466
+ logoutBtn.classList.toggle('is-root', user === 'root');
466
467
  navUsers.hidden = user !== 'root';
467
468
  document.body.classList.add('authenticated');
468
469
  } else {
@@ -543,6 +544,18 @@
543
544
  });
544
545
  }
545
546
 
547
+ // Reload current view when clicking an already-active nav link
548
+ document.getElementById('main-nav').addEventListener('click', function (e) {
549
+ if (e.target.tagName === 'A') {
550
+ var href = e.target.getAttribute('href') || '#';
551
+ var current = window.location.hash || '#';
552
+ if (href === current) {
553
+ e.preventDefault();
554
+ navigate();
555
+ }
556
+ }
557
+ });
558
+
546
559
  logoutBtn.addEventListener('click', function (e) {
547
560
  // Clicking the username part opens change-password modal
548
561
  if (e.target.classList.contains('user-btn-name')) {
@@ -634,7 +647,7 @@
634
647
  var currentUser = Api.getUser();
635
648
  var canEdit = currentUser === name || currentUser === 'root';
636
649
 
637
- var card = el('div', {className: 'index-card user-card'});
650
+ var card = el('div', {className: 'index-card user-card' + (name === 'root' ? ' user-root' : '')});
638
651
 
639
652
  // Card head: username + kebab menu
640
653
  var cardHead = el('div', {className: 'index-card-head'});
@@ -1172,6 +1185,12 @@
1172
1185
 
1173
1186
  var PKG_LIMIT = 100;
1174
1187
 
1188
+ function canDeleteFromIndex(indexPath) {
1189
+ var user = Api.getUser();
1190
+ if (!user) return false;
1191
+ return user === 'root' || user === indexPath.split('/')[0];
1192
+ }
1193
+
1175
1194
  function buildPackageCard(indexPath, pkg, fetchVersion) {
1176
1195
  var card = el('div', {className: 'pkg-card'});
1177
1196
  var cardHead = el('div', {className: 'pkg-card-head'});
@@ -1181,9 +1200,11 @@
1181
1200
  textContent: pkg,
1182
1201
  }));
1183
1202
 
1184
- cardHead.appendChild(buildKebabMenu([
1185
- {label: 'Delete all versions', danger: true, onclick: function () { closeAllKebabs(); deletePackage(indexPath, pkg); }},
1186
- ]));
1203
+ if (canDeleteFromIndex(indexPath)) {
1204
+ cardHead.appendChild(buildKebabMenu([
1205
+ {label: 'Delete all versions', danger: true, onclick: function () { closeAllKebabs(); deletePackage(indexPath, pkg); }},
1206
+ ]));
1207
+ }
1187
1208
  card.appendChild(cardHead);
1188
1209
 
1189
1210
  card.appendChild(buildPipBlock(indexPath, pkg));
@@ -1227,7 +1248,8 @@
1227
1248
  function fetchStage() {
1228
1249
  showHeadingAndLoading(false);
1229
1250
  Api.get('/' + indexPath).then(function (data) {
1230
- renderPackages(indexPath, data.result, false);
1251
+ var resultIsMirror = !!(data.result && data.result.type === 'mirror');
1252
+ renderPackages(indexPath, data.result, resultIsMirror);
1231
1253
  }).catch(handleApiError);
1232
1254
  }
1233
1255
 
@@ -1430,13 +1452,13 @@
1430
1452
  ' ',
1431
1453
  el('span', {className: 'page-heading-version', textContent: 'v' + currentVer}),
1432
1454
  ]),
1433
- el('div', {className: 'view-header-actions'}, [
1455
+ el('div', {className: 'view-header-actions'}, canDeleteFromIndex(indexPath) ? [
1434
1456
  el('button', {
1435
- className: 'btn btn-danger auth-only',
1457
+ className: 'btn btn-danger',
1436
1458
  textContent: 'Delete package',
1437
1459
  onclick: function () { deletePackage(indexPath, pkg); },
1438
1460
  }),
1439
- ]),
1461
+ ] : []),
1440
1462
  ]));
1441
1463
 
1442
1464
  if (cachedVersions.length === 0 && !allVersions) {
@@ -1959,10 +1981,55 @@
1959
1981
  grid.appendChild(whooshCard);
1960
1982
  }
1961
1983
 
1984
+ // Replicas — only shown on master with connected replicas
1985
+ var pollingReplicas = status.polling_replicas || {};
1986
+ var replicaUuids = Object.keys(pollingReplicas);
1987
+ if (status.role === 'MASTER' && replicaUuids.length > 0) {
1988
+ var masterSerial = status.serial || 0;
1989
+ var now = Date.now() / 1000;
1990
+ // Replica is considered offline if it hasn't polled in >90s
1991
+ // (normal polling interval is ~37.5s)
1992
+ var OFFLINE_THRESHOLD = 90;
1993
+
1994
+ for (var ri = 0; ri < replicaUuids.length; ri++) {
1995
+ var uuid = replicaUuids[ri];
1996
+ var rep = pollingReplicas[uuid];
1997
+ var lastRequest = rep['last-request'] || 0;
1998
+ var age = now - lastRequest;
1999
+ var isOnline = rep['in-request'] || age < OFFLINE_THRESHOLD;
2000
+ var lag = masterSerial - (rep['serial'] || 0);
2001
+ var label = rep['remote-ip'] || uuid.substring(0, 8);
2002
+
2003
+ var repCard = el('div', {className: 'status-card'});
2004
+ var titleRow = el('div', {className: 'status-card-title replica-title'}, [
2005
+ el('span', {textContent: 'Replica: ' + label}),
2006
+ el('span', {
2007
+ className: 'replica-badge ' + (isOnline ? 'replica-online' : 'replica-offline'),
2008
+ textContent: isOnline ? 'online' : 'offline',
2009
+ }),
2010
+ ]);
2011
+ repCard.appendChild(titleRow);
2012
+ repCard.appendChild(statusRow('Serial lag', lag === 0 ? 'in sync' : '+' + lag));
2013
+ repCard.appendChild(statusRow('Last seen', _formatAge(age)));
2014
+ repCard.appendChild(statusRow('Polling', rep['in-request'] ? 'active' : 'idle'));
2015
+ if (rep['outside-url']) {
2016
+ repCard.appendChild(statusRow('URL', rep['outside-url']));
2017
+ }
2018
+ grid.appendChild(repCard);
2019
+ }
2020
+ }
2021
+
1962
2022
  content.appendChild(grid);
1963
2023
  }).catch(handleApiError);
1964
2024
  }
1965
2025
 
2026
+ function _formatAge(seconds) {
2027
+ if (seconds < 5) return 'just now';
2028
+ if (seconds < 60) return Math.floor(seconds) + 's ago';
2029
+ if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago';
2030
+ return Math.floor(seconds / 3600) + 'h ago';
2031
+ }
2032
+
1966
2033
  function formatNum(n) {
1967
2034
  if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
1968
2035
  if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
@@ -1981,4 +2048,22 @@
1981
2048
  updateAuthUI();
1982
2049
  updateNav();
1983
2050
  navigate();
2051
+
2052
+ var _sessionCheckReady = false;
2053
+ setTimeout(function () { _sessionCheckReady = true; }, 2000);
2054
+
2055
+ function checkSession() {
2056
+ if (!_sessionCheckReady || !Api.getUser()) return;
2057
+ Api.get('/+admin-api/session').catch(function (err) {
2058
+ if (err.status === 403 || err.status === 401) {
2059
+ Api.logout();
2060
+ updateAuthUI();
2061
+ showError(new Error('Session expired. Please log in again.'));
2062
+ }
2063
+ });
2064
+ }
2065
+ document.addEventListener('visibilitychange', function () {
2066
+ if (document.visibilityState === 'visible') checkSession();
2067
+ });
2068
+ window.addEventListener('focus', checkSession);
1984
2069
  })();
@@ -0,0 +1,231 @@
1
+ Metadata-Version: 2.4
2
+ Name: devpi-admin
3
+ Version: 1.2.0
4
+ Summary: Modern web UI plugin for devpi-server — drop-in replacement for devpi-web
5
+ Author-email: Pavel Revak <pavelrevak@gmail.com>
6
+ License: MIT
7
+ Keywords: devpi,pypi,admin,web,ui
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Framework :: Pyramid
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: System Administrators
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: Libraries
16
+ Classifier: Topic :: System :: Software Distribution
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: devpi-server>=6.0
21
+ Dynamic: license-file
22
+
23
+ # devpi-admin
24
+
25
+ A modern web UI plugin for [devpi-server](https://devpi.net/) — a drop-in replacement for
26
+ `devpi-web`. Ships as a Python package that registers itself as a devpi-server plugin via the
27
+ standard entry point mechanism, so a single `pip install devpi-admin` is enough.
28
+
29
+ The UI itself is a bundled single-page application (pure HTML + CSS + vanilla JavaScript, no
30
+ build step) served under `/+admin/`. All devpi REST API endpoints remain untouched — the SPA
31
+ talks to the standard devpi JSON API directly.
32
+
33
+ ## Features
34
+
35
+ ### Dashboard
36
+ - Server info with version of devpi-server and all installed plugins (auto-detected)
37
+ - Cache metrics with hit-rate bars (storage, changelog, relpath caches)
38
+ - Whoosh search index queue status
39
+ - **Replica status** (master only) — per-replica cards with online/offline badge, serial lag,
40
+ and last-seen timestamp; visible only when replicas are connected
41
+
42
+ ### Indexes
43
+ - Visual cards color-coded by type: green (stage), amber (volatile stage), blue (mirror)
44
+ - `pip install` command with copy-to-clipboard (click to copy, green flash feedback)
45
+ - `pip.conf` toggle — switch between short form and full `--index-url` / `--trusted-host`
46
+ - `pip.conf` generator — download a ready-to-use config per index
47
+ - Create / edit / delete indexes via modal dialogs
48
+ - `bases` editor with drag & drop priority ordering and transitive inheritance display
49
+ - `acl_upload` tag picker with user selection dropdown
50
+ - `volatile`, `mirror_url`, `title` configuration
51
+
52
+ ### Users
53
+ - Create, edit (email, password), delete users (admin only)
54
+
55
+ ### Packages
56
+ - Client-side search with PEP 503 name normalization
57
+ - Mirror indexes: shows only cached packages (filesystem scan, no 17 MB index download);
58
+ "Download full index" button available for complete browse
59
+ - Package cards with latest version and `pip install` command
60
+
61
+ ### Package detail (PyPI-like layout)
62
+ - **Sidebar**: metadata (author, license, Python version, keywords, platform, maintainer,
63
+ extras, project URLs, dependencies), `pip install` command, file downloads with upload dates
64
+ - **Version list**: cached versions shown normally, uncached versions link to pypi.org (↗);
65
+ "Load all versions" button for mirrors
66
+ - **README**: rendered markdown (via `marked.js`); fetched from PyPI.org for mirror packages
67
+ where devpi doesn't cache the description
68
+
69
+ ### General
70
+ - **Anonymous browsing** — visitors can explore public indexes without logging in; admin
71
+ actions (create/edit/delete) appear only after authentication
72
+ - **Dark / light / auto theme** with half-circle icon for auto mode
73
+ - **Responsive mobile menu** with hamburger toggle
74
+ - **ESC + outside-click** dismissal for modals, dropdown menus, mobile menu
75
+ - **Login via modal** — no separate login page
76
+
77
+ ## Plugin API endpoints
78
+
79
+ In addition to serving the SPA, `devpi-admin` registers custom API endpoints under
80
+ `/+admin-api/` for features that the standard devpi REST API doesn't provide efficiently:
81
+
82
+ | Endpoint | Method | Description |
83
+ |----------|--------|-------------|
84
+ | `/+admin-api/cached/{user}/{index}` | GET | List cached package names for a mirror index (filesystem scan) |
85
+ | `/+admin-api/versions/{user}/{index}/{project}` | GET | Version list with cached/uncached distinction |
86
+ | `/+admin-api/versions/{user}/{index}/{project}?all=1` | GET | Include all upstream versions (mirrors) |
87
+ | `/+admin-api/versiondata/{user}/{index}/{project}/{version}` | GET | Metadata + file links for a single version |
88
+
89
+ ## Installation
90
+
91
+ ```bash
92
+ pip install devpi-admin
93
+ ```
94
+
95
+ This pulls in `devpi-server` as a dependency. If you are using devpi in a dedicated venv
96
+ (recommended), install the plugin into the same venv:
97
+
98
+ ```bash
99
+ /var/lib/pypi/venv/bin/pip install devpi-admin
100
+ systemctl --user restart devpi # or however you run devpi-server
101
+ ```
102
+
103
+ You should uninstall `devpi-web` — `devpi-admin` replaces it entirely:
104
+
105
+ ```bash
106
+ pip uninstall devpi-web
107
+ ```
108
+
109
+ Both plugins can technically coexist but it is not recommended. `devpi-admin` intercepts `/`
110
+ for HTML requests while `devpi-web` would still serve its own HTML on other routes like
111
+ `/<user>/<index>/<package>`, leading to a confusing mixed experience.
112
+
113
+ ## Usage
114
+
115
+ After restart, open:
116
+
117
+ ```
118
+ http://<your-devpi-host>:3141/
119
+ ```
120
+
121
+ Browser visits to `/` are redirected to `/+admin/`, which serves the SPA. Direct links like
122
+ `http://<host>:3141/+admin/#packages/ci/testing` work and can be bookmarked.
123
+
124
+ devpi CLI tools and other JSON clients are unaffected — they send `Accept: application/json`
125
+ and bypass the redirect.
126
+
127
+ ## How it works
128
+
129
+ `devpi-admin` registers a `devpi_server` entry point that hooks into
130
+ `devpiserver_pyramid_configure` (with `@hookimpl` from pluggy) to:
131
+
132
+ 1. Serve the bundled static assets under `/+admin/` via a Pyramid static view.
133
+ 2. Add an explicit view at `/+admin/` that returns `index.html`.
134
+ 3. Register custom API views under `/+admin-api/` for cached-package and per-version queries.
135
+ 4. Install a tween that redirects HTML browser requests on `/` to `/+admin/` while leaving
136
+ JSON requests intact.
137
+
138
+ The plugin uses devpi-server internals (`xom.model.getstage`, `stage.list_versions`,
139
+ `stage.get_versiondata`, `stage.get_releaselinks`) and direct filesystem access
140
+ (`serverdir/+files/`) for the cached-packages API.
141
+
142
+ ## Requirements
143
+
144
+ - Python 3.9+
145
+ - devpi-server 6.0+
146
+ - A browser with ES6 support (`Promise`, `fetch`, `sessionStorage`)
147
+
148
+ ## Routes (UI)
149
+
150
+ Routing is hash-based, so any of these URLs can be bookmarked or shared:
151
+
152
+ | Hash | View |
153
+ |------|------|
154
+ | `#` | Status dashboard (default) |
155
+ | `#indexes` | All indexes |
156
+ | `#indexes/<user>` | Indexes filtered by user |
157
+ | `#packages/<user>/<index>` | Packages in an index |
158
+ | `#package/<user>/<index>/<name>` | Package detail (latest cached version) |
159
+ | `#package/<user>/<index>/<name>?version=<ver>` | Specific version |
160
+ | `#users` | User management (requires login) |
161
+
162
+ ## Project layout
163
+
164
+ ```
165
+ devpi-admin/
166
+ ├── pyproject.toml
167
+ ├── README.md
168
+ ├── LICENSE
169
+ ├── .github/workflows/
170
+ │ ├── tests.yml — CI on push/PR (Python 3.10 + 3.14)
171
+ │ └── publish.yml — publish to PyPI on release
172
+ ├── src/
173
+ │ └── devpi_admin/
174
+ │ ├── __init__.py — version (from git tag via hatch-vcs)
175
+ │ ├── main.py — Pyramid hooks, tween, API views
176
+ │ └── static/
177
+ │ ├── index.html — SPA entry point
178
+ │ ├── css/style.css
179
+ │ └── js/
180
+ │ ├── api.js — devpi REST wrapper + auth
181
+ │ ├── theme.js — theme toggle (light/dark/auto)
182
+ │ ├── marked.min.js — vendored markdown renderer
183
+ │ └── app.js — routing, views, rendering
184
+ └── tests/
185
+ ├── test_cached_versions.py — filesystem scan (tmpdir)
186
+ ├── test_helpers.py — filename parsing, normalization
187
+ ├── test_hooks.py — pluggy hook registration
188
+ ├── test_json_safe.py — readonly view conversion
189
+ ├── test_package.py — entry point, static files
190
+ ├── test_tween.py — redirect behavior
191
+ └── test_wants_html.py — Accept header heuristic
192
+ ```
193
+
194
+ ## Development
195
+
196
+ ```bash
197
+ git clone <repo>
198
+ cd devpi-admin
199
+ python -m venv .venv
200
+ .venv/bin/pip install -e .
201
+ ```
202
+
203
+ The static files live at `src/devpi_admin/static/` and can be edited in place — changes
204
+ show up on the next browser reload, no restart of devpi-server required (static views
205
+ read from disk on each request). Python changes (`main.py`) require a devpi-server restart.
206
+
207
+ Run the unit tests:
208
+
209
+ ```bash
210
+ PYTHONWARNINGS="ignore::UserWarning" python -m unittest discover -v tests/
211
+ ```
212
+
213
+ (The `PYTHONWARNINGS` shim hides an unrelated deprecation warning emitted by Pyramid 2.1
214
+ when it imports `pkg_resources`.)
215
+
216
+ ## Releasing
217
+
218
+ Version is derived from the git tag via `hatch-vcs`. To release:
219
+
220
+ 1. `git tag v0.1.0 && git push --tags`
221
+ 2. On GitHub: Releases → Draft new release → select tag → Publish
222
+ 3. The `publish.yml` workflow runs tests, builds wheel+sdist, and uploads to PyPI via trusted
223
+ publishing (no API tokens needed — configure the GitHub environment `pypi` in PyPI settings).
224
+
225
+ ## Author
226
+
227
+ Pavel Revak <pavelrevak@gmail.com>
228
+
229
+ ## License
230
+
231
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,31 @@
1
+ .gitignore
2
+ INSTALL.textile
3
+ LICENSE
4
+ README.md
5
+ pyproject.toml
6
+ .github/workflows/publish.yml
7
+ .github/workflows/tests.yml
8
+ devpi_admin/__init__.py
9
+ devpi_admin/_version.py
10
+ devpi_admin/main.py
11
+ devpi_admin.egg-info/PKG-INFO
12
+ devpi_admin.egg-info/SOURCES.txt
13
+ devpi_admin.egg-info/dependency_links.txt
14
+ devpi_admin.egg-info/entry_points.txt
15
+ devpi_admin.egg-info/requires.txt
16
+ devpi_admin.egg-info/top_level.txt
17
+ devpi_admin/static/favicon.svg
18
+ devpi_admin/static/index.html
19
+ devpi_admin/static/css/style.css
20
+ devpi_admin/static/js/api.js
21
+ devpi_admin/static/js/app.js
22
+ devpi_admin/static/js/marked.min.js
23
+ devpi_admin/static/js/theme.js
24
+ tests/__init__.py
25
+ tests/test_cached_versions.py
26
+ tests/test_helpers.py
27
+ tests/test_hooks.py
28
+ tests/test_json_safe.py
29
+ tests/test_package.py
30
+ tests/test_tween.py
31
+ tests/test_wants_html.py
@@ -0,0 +1,2 @@
1
+ [devpi_server]
2
+ devpi-admin = devpi_admin.main
@@ -0,0 +1 @@
1
+ devpi-server>=6.0
@@ -0,0 +1 @@
1
+ devpi_admin
@@ -1,6 +1,6 @@
1
1
  [build-system]
2
- requires = ["hatchling", "hatch-vcs"]
3
- build-backend = "hatchling.build"
2
+ requires = ["setuptools", "setuptools-scm"]
3
+ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devpi-admin"
@@ -31,21 +31,9 @@ dependencies = [
31
31
  [project.entry-points.devpi_server]
32
32
  devpi-admin = "devpi_admin.main"
33
33
 
34
- [tool.hatch.version]
35
- source = "vcs"
36
- fallback-version = "0.0.0"
34
+ [tool.setuptools.packages.find]
35
+ include = ["devpi_admin*"]
37
36
 
38
- [tool.hatch.build.hooks.vcs]
39
- version-file = "src/devpi_admin/_version.py"
40
-
41
- [tool.hatch.build.targets.wheel]
42
- packages = ["src/devpi_admin"]
43
-
44
- [tool.hatch.build.targets.sdist]
45
- include = [
46
- "src/devpi_admin",
47
- "tests",
48
- "README.md",
49
- "pyproject.toml",
50
- "LICENSE",
51
- ]
37
+ [tool.setuptools_scm]
38
+ write_to = "devpi_admin/_version.py"
39
+ fallback_version = "0.0.0"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes