Compare commits

..

9 Commits
1.4.8 ... 1.4.9

Author SHA1 Message Date
Joxit
f9620639bf build: release 1.4.9 new options SHOW_CONTENT_DIGEST and CATALOG_ELEMENTS_LIMIT
Improve view for very long tags in taglist
2020-06-01 12:31:48 +02:00
Joxit
d1700ccf74 chore: update README for new option CATALOG_ELEMENTS_LIMIT 2020-05-31 01:19:18 +02:00
Joxit
991eaf932d feat: add content element limit
closes: #127
2020-05-31 01:18:55 +02:00
Joxit
e2ee319d4a chore: update readme for SHOW_CONTENT_DIGEST 2020-05-31 01:07:17 +02:00
Joxit
06d6293e79 feat: reduce the sha hash size when the tag is too long 2020-05-31 01:07:17 +02:00
Joxit
00fe443a7c feat: Hide SHA-Hash column
closes #126
2020-05-31 01:07:17 +02:00
Joxit
6e7fc1508e Add CORS alternative solution in README 2020-05-18 23:08:52 +02:00
Joxit
178cd5a59d chore: move electron to examples folder 2020-05-11 11:13:07 +02:00
Manuel Leitold
da9591609e Add Electron-based Standalone Application (#129)
* add electron app
* add some readme
* add more documentation
* add a password fix for windows
* format code
* overwrite existing dists
* build app first before building electron app
* add authentication
* add build
* use material ui for credentials
* add application bar
* open dev tools only in dev mode
* cleanup code
* disable add button if a new item is added
* do not always create credentials helper - create it once
* improve add button
* do not make credential helper modal
* use dark mode if user prefers it
* disable menubar in credentials window
* clean up package json
* show windows first when all DOMs are loaded
* remove save button
* write documentation
* load credentials after credentials helper is closed
* execute npm install first
* add gif animation for the credential helper
2020-05-11 10:57:10 +02:00
21 changed files with 608 additions and 16 deletions

3
.gitignore vendored
View File

@@ -3,4 +3,5 @@ node_modules
package-lock.json
registry-data
.idea
_site
_site
*.orig

View File

@@ -11,6 +11,7 @@
- Sébastien Huss [@sebt3](https://github.com/sebt3)
- Vladimir Kozyrev [@fieryvova](https://github.com/fieryvova)
- Haibo Jia [@bluethon](https://github.com/bluethon)
- Manuel Leitold [@agrippa1994](https://github.com/agrippa1994)
## Because committers are not the only contributors
@@ -26,4 +27,6 @@
- [@marcusblake](https://github.com/marcusblake)
- Dario [@pidario](https://github.com/pidario)
- Jernej K. [@Cvetk0](https://github.com/Cvetk0)
- Cristian Posoiu [@cr1st1p](https://github.com/cr1st1p)
- Cristian Posoiu [@cr1st1p](https://github.com/cr1st1p)
- Sepp Zuther [@Herr-Sepp](https://github.com/Herr-Sepp)
- Tomas Hulata [@tombokombo](https://github.com/tombokombo)

View File

@@ -46,6 +46,8 @@ This web user interface uses [Riot](https://github.com/Riot/riot) the react-like
- Add Title when using `REGISTRY_URL` (see [#28](https://github.com/Joxit/docker-registry-ui/issues/28)) **static interface**.
- Customise docker pull command on static registry UI (see [#71](https://github.com/Joxit/docker-registry-ui/issues/71)) **static interface**.
- Add custom header via environment variable and file via `NGINX_PROXY_HEADER_*` (see [#89](https://github.com/Joxit/docker-registry-ui/pull/89)) **static interface**
- Show/Hide content digest in taglist via `SHOW_CONTENT_DIGEST` (values are: [`true`, `false`], default: `true`) (see [#126](https://github.com/Joxit/docker-registry-ui/issues/126)).
- Limit the number of elements in the image list via `CATALOG_ELEMENTS_LIMIT` (see [#127](https://github.com/Joxit/docker-registry-ui/pull/127)).
## FAQ
@@ -65,6 +67,8 @@ This web user interface uses [Riot](https://github.com/Riot/riot) the react-like
- This fixes the issue [#88](https://github.com/Joxit/docker-registry-ui/issues/88). More about this in [#113](https://github.com/Joxit/docker-registry-ui/issues/113).
- Why DELETE fails with 401 status code (using Basic Auth) ?
- This is caused by a bug in docker registry, I suggest to have your UI on the same domain than your registry e.g. registry.example.com/ui/. (see [#104](https://github.com/Joxit/docker-registry-ui/issues/104)).
- Can I use the docker registry ui as a standalone application (with Electron) ?
- Yes, check out the example [here](https://github.com/Joxit/docker-registry-ui/tree/master/examples/electron). (see [#129](https://github.com/Joxit/docker-registry-ui/pull/129))
Need more informations ? Try my [examples](https://github.com/Joxit/docker-registry-ui/tree/master/examples) or open an issue.
@@ -184,6 +188,8 @@ http:
Access-Control-Allow-Methods: ['HEAD', 'GET', 'OPTIONS'] # Optional
```
An alternative for CORS issues is a plugin on your browser, more info [here](https://github.com/Joxit/docker-registry-ui/issues/25#issuecomment-621104846) (thank you [xmontero](https://github.com/xmontero)).
## Using delete
For deleting images, you need to activate the delete feature in your registry:
@@ -235,6 +241,10 @@ auth:
path: /etc/docker/registry/htpasswd
```
## Standalone Application
If you do not want to install the docker-registry-ui on your server, you may
check out the [Electron](examples/electron/README.md) standalone application.
## All examples
- [Use docker-registry-ui as a proxy (use REGISTRY_URL)](https://github.com/Joxit/docker-registry-ui/tree/master/examples/ui-as-proxy)
@@ -245,4 +255,5 @@ auth:
- [Use docker-registry-ui with HTTPS](https://github.com/Joxit/docker-registry-ui/tree/master/examples/issue-20) ([#20](https://github.com/Joxit/docker-registry-ui/issues/20))
- [Unable to push image when docker-registry-ui is used as a proxy on non 80 port](https://github.com/Joxit/docker-registry-ui/tree/master/examples/issue-88) ([#88](https://github.com/Joxit/docker-registry-ui/issues/88))
- [Add custom headers bases on environment variable and/or file when the ui is used as proxy](https://github.com/Joxit/docker-registry-ui/tree/master/examples/proxy-headers) ([#89](https://github.com/Joxit/docker-registry-ui/pull/89))
- [UI showing same sha256 content digest for all tags + Delete is not working](https://github.com/Joxit/docker-registry-ui/tree/master/examples/issue-116) ([#116](https://github.com/Joxit/docker-registry-ui/issues/116))
- [UI showing same sha256 content digest for all tags + Delete is not working](https://github.com/Joxit/docker-registry-ui/tree/master/examples/issue-116) ([#116](https://github.com/Joxit/docker-registry-ui/issues/116))
- [Electron-based Standalone Application](https://github.com/Joxit/docker-registry-ui/tree/master/examples/electron) ([#129](https://github.com/Joxit/docker-registry-ui/pull/129))

View File

@@ -8,6 +8,14 @@ if [ -z "${DELETE_IMAGES}" ] || [ "${DELETE_IMAGES}" = false ] ; then
sed -i -r "s/(isImageRemoveActivated[:=])[^,;]*/\1false/" scripts/docker-registry-ui.js
fi
if [ "${SHOW_CONTENT_DIGEST}" = false ] ; then
sed -i -r "s/(showContentDigest[:=])[^,;]*/\1false/" scripts/docker-registry-ui.js
fi
if [ -n "${CATALOG_ELEMENTS_LIMIT}" ] ; then
sed -i -r "s/(catalogElementsLimit[:=])[^,;]*/\1${CATALOG_ELEMENTS_LIMIT}/" scripts/docker-registry-ui.js
fi
get_nginx_proxy_headers() {
(
env &&

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
dist/style.css vendored

File diff suppressed because one or more lines are too long

8
examples/electron/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
# NPM renames .gitignore to .npmignore
# In order to prevent that, we remove the initial "."
# And the CLI then renames it
dist/
node_modules/
Registry*
.cache

View File

@@ -0,0 +1,57 @@
# Standalone Application
## Overview
This standalone application is based on Electron which encapsulates the whole
docker-registry-ui in a single executable, that can be run on your local
computer.
## Building
* Check out or download the repository, open a terminal at the checkout
directory, download the dependencies and build the web app:
```bash
npm install
npm run build
```
* After building the web application, navigate to the ```electron``` directory
and execute following commands to build the executable:
```bash
cd electron
npm install
npm run dist
```
If you encounter any issues, please check the troubleshooting below.
## Password Protected Registries
If you want to interact with password protected Docker Registries, this
application will use the keystore of your system to gather the credentials for
accessing the Registry.
This is accomplished with the [keytar](https://www.npmjs.com/package/keytar)
package. In concjunction with keytar, the integrated credential
helper supports you with managing the credentials to the Registries.
![alt Authentication on macOS](./doc/assets/authentication.gif)
## Troubleshooting
* Problem: The application does not start with ```npm start``` and exits with following message:
```
[7742:0509/001117.199224:FATAL:setuid_sandbox_host.cc(157)] The SUID sandbox helper binary was found, but is not configured correctly. Rather than run without sandboxing I'm aborting now. You need to make sure that ./node_modules/electron dist/chrome-sandbox is owned by root and has mode 4755.
```
Solution: Add proper rights to the chrome-sanbox
```bash
sudo chown root ./node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 ./node_modules/electron/dist/chrome-sandbox
```
* Problem: I am on Linux and to not have any password wallet for keytar.
Solution: Install following dependencies according to the official [setup instructions](https://atom.github.io/node-keytar/) for keytar on Linux:
* Debian/Ubuntu: ```sudo apt-get install libsecret-1-dev```
* Red Hat-based: ```sudo yum install libsecret-devel```
* Arch Linux: ```sudo pacman -S libsecret```

View File

@@ -0,0 +1,8 @@
<html>
<body>
<div id="root"></div>
<script src="index.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,211 @@
import * as React from "react";
import {useEffect, useState} from "react";
import {render} from "react-dom";
import * as keytar from 'keytar';
import {ipcRenderer} from 'electron';
import {
Button,
createMuiTheme,
CssBaseline,
IconButton,
LinearProgress,
makeStyles,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
ThemeProvider,
useMediaQuery
} from "@material-ui/core";
import {Alert, AlertTitle} from '@material-ui/lab';
import {blue} from "@material-ui/core/colors";
import {Add as AddIcon, Delete as DeleteIcon, Save as SaveIcon} from "@material-ui/icons";
const mainStyle = makeStyles((theme) => ({
root: {
padding: theme.spacing(2),
display: "flex",
flexDirection: 'column',
width: '100%',
height: '100%',
},
main: {
flexGrow: 1,
paddingTop: theme.spacing(2),
paddingBottom: theme.spacing(2),
},
input: {
width: '100%',
},
}));
function CredentialRow({credential, index, onDelete, onUpdate}) {
const [account, setAccount] = useState(credential?.account || '');
const [password, setPassword] = useState(credential?.password || '');
const style = mainStyle();
return (<TableRow>
<TableCell>
<TextField
className={style.input}
type="text"
placeholder='https://user@someregistry:5000/'
value={account} variant="outlined"
onChange={(e) => {
setAccount(e.target.value)
}}/>
</TableCell>
<TableCell>
<TextField type="password"
className={style.input}
variant="outlined"
placeholder='Password'
value={password}
onChange={(e) => {
setPassword(e.target.value)
}}/>
</TableCell>
<TableCell align="right">
<IconButton onClick={async () => await onUpdate(credential, index, {account, password})}>
<SaveIcon/>
</IconButton>
<IconButton onClick={async () => await onDelete(credential, index,)}>
<DeleteIcon/>
</IconButton>
</TableCell>
</TableRow>);
}
function CredentialsTable({onError}) {
const [credentials, setCredentials] = useState(null);
async function loadItems() {
try {
const credentials = await keytar.findCredentials('docker-registry-ui');
for (const credential of credentials) {
// fix for windows
credential.password = credential.password.replace(/\000+/g, '');
}
setCredentials(credentials);
} catch (e) {
onError(e.toString());
}
}
async function handleDelete(item, index) {
// delete an item that has not been stored yet
if (!item) {
const newCredentials = [...credentials];
newCredentials.splice(index, 1);
setCredentials(newCredentials);
return;
}
try {
await keytar.deletePassword('docker-registry-ui', item.account);
await loadItems();
} catch (e) {
onError(e.toString());
}
}
async function handleUpdate(oldCredentials, index, newCredentials) {
try {
await handleDelete(oldCredentials, index);
await keytar.setPassword('docker-registry-ui', newCredentials.account, newCredentials.password);
await loadItems();
} catch (e) {
console.error("Error while updating key: ", e);
onError(e.toString());
}
}
useEffect(() => {
const load = async () => {
await loadItems();
};
load();
return;
}, []);
if (credentials === null) {
return <LinearProgress/>
}
return (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Host of the registry including username</TableCell>
<TableCell>Password</TableCell>
<TableCell align='right'>
<IconButton onClick={() => {
setCredentials([...credentials, null])
}} disabled={credentials.includes(null)}>
<AddIcon/>
</IconButton>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{credentials.map((credential, index) => <CredentialRow
onDelete={handleDelete}
onUpdate={handleUpdate}
index={index}
key={credential?.account || ''}
credential={credential}/>)}
</TableBody>
</Table>
</TableContainer>
);
}
function App() {
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
const [error, setError] = useState();
const classes = mainStyle();
const theme = React.useMemo(
() =>
createMuiTheme({
palette: {
type: prefersDarkMode ? 'dark' : 'light',
primary: blue,
},
}),
[prefersDarkMode],
);
return (
<ThemeProvider theme={theme}>
<CssBaseline/>
<div className={classes.root}>
{error && <Alert severity='error' onClose={() => {
setError(null)
}}>
<AlertTitle>Error</AlertTitle>
{error}
</Alert>}
<main className={classes.main}>
<CredentialsTable onError={setError}/>
</main>
</div>
</ThemeProvider>
);
}
render(<App/>, document.getElementById("root"));
// @ts-ignore
if (module.hot) {
// @ts-ignore
module.hot.accept();
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

229
examples/electron/index.js Normal file
View File

@@ -0,0 +1,229 @@
const {app, BrowserWindow, globalShortcut, Menu} = require('electron');
const isDevMode = require('electron-is-dev');
const keytar = require('keytar');
const url = require('url');
const isMac = process.platform === 'darwin'
// Place holders for our windows so they don't get garbage collected.
let mainWindow = null;
// Credentials that are fetched from the Keychain
let credentials = [];
// Credentials helper window
let credentialsWindow;
const template = [
// { role: 'appMenu' }
...(isMac ? [{
label: app.name,
submenu: [
{role: 'about'},
{type: 'separator'},
{
label: 'Preferences', accelerator: 'CmdorCtrl+,', click: () => {
credentialsWindow.show();
}
},
{type: 'separator'},
{role: 'hide'},
{role: 'hideothers'},
{role: 'unhide'},
{type: 'separator'},
{role: 'quit'}
]
}] : []),
// { role: 'fileMenu' }
{
label: 'File',
submenu: [
...(isMac ? [] : [{role: 'quit'}]),
{
label: 'Preferences', accelerator: 'CmdorCtrl+,', click: () => {
credentialsWindow.show();
}
},
]
},
// { role: 'editMenu' }
{
label: 'Edit',
submenu: [
{role: 'undo'},
{role: 'redo'},
{type: 'separator'},
{role: 'cut'},
{role: 'copy'},
{role: 'paste'},
...(isMac ? [
{role: 'pasteAndMatchStyle'},
{role: 'delete'},
{role: 'selectAll'},
{type: 'separator'},
{
label: 'Speech',
submenu: [
{role: 'startspeaking'},
{role: 'stopspeaking'}
]
}
] : [
{role: 'delete'},
{type: 'separator'},
{role: 'selectAll'}
])
]
},
// { role: 'viewMenu' }
{
label: 'View',
submenu: [
{role: 'reload'},
{role: 'forcereload'},
{role: 'toggledevtools'},
{type: 'separator'},
{role: 'resetzoom'},
{role: 'zoomin'},
{role: 'zoomout'},
{type: 'separator'},
{role: 'togglefullscreen'},
{type: 'separator'},
{
label: 'Credentials Helper', accelerator: 'CmdorCtrl+k', click: () => {
credentialsWindow.show();
}
},
]
},
// { role: 'windowMenu' }
{
label: 'Window',
submenu: [
{role: 'minimize'},
{role: 'zoom'},
...(isMac ? [
{type: 'separator'},
{role: 'front'},
{type: 'separator'},
{role: 'window'}
] : [
{role: 'close'}
])
]
},
{
role: 'help',
submenu: [
{
label: 'Learn More',
click: async () => {
const {shell} = require('electron')
await shell.openExternal('https://joxit.dev/docker-registry-ui/')
}
}
]
}
];
const menu = Menu.buildFromTemplate(template);
if (isMac) {
Menu.setApplicationMenu(menu);
}
async function loadCredentials() {
try {
credentials = await keytar.findCredentials('docker-registry-ui');
for (const credential of credentials) {
// fix for windows
credential.password = credential.password.replace(/\000+/g, '');
}
} catch (e) {
console.log(e);
credentials = [];
}
}
async function createWindow() {
return new Promise((resolve, reject) => {
mainWindow = new BrowserWindow({
height: 920,
width: 1600,
show: false,
webPreferences: {
nodeIntegration: false,
}
});
if (isDevMode) {
mainWindow.webContents.openDevTools();
}
if (!isMac) {
mainWindow.setMenu(menu);
}
mainWindow.loadURL(`file://${__dirname}/dist/index.html`);
mainWindow.webContents.on('dom-ready', () => {
console.log("Main Window DOM ready");
resolve();
});
});
}
async function createCredentialsWindow() {
return new Promise((resolve) => {
credentialsWindow = new BrowserWindow({
width: 1000,
height: 400,
show: false,
title: 'Credential Manager',
parent: mainWindow,
webPreferences: {
nodeIntegration: true,
}
});
if (isDevMode) {
credentialsWindow.openDevTools();
}
if (!isMac) {
credentialsWindow.setMenu(null);
}
credentialsWindow.loadURL(`file://${__dirname}/dist/authentication/index.html`);
credentialsWindow.webContents.on('dom-ready', () => {
console.log('Credentials Window DOM is ready');
resolve();
});
credentialsWindow.on('close', async (e) => {
console.log("Closed credential window");
credentialsWindow.hide();
e.preventDefault();
await loadCredentials();
mainWindow.reload();
});
});
}
app.on('ready', async () => {
await Promise.all([
loadCredentials(),
createWindow(),
createCredentialsWindow(),
]);
mainWindow.show();
});
app.on("login", (event, contents, authencation, info, callback) => {
for (const credential of credentials) {
const parsedUrl = url.parse(credential.account);
if (parsedUrl.hostname === info.host) {
return callback(parsedUrl.auth, credential.password);
}
}
callback();
});

View File

@@ -0,0 +1,39 @@
{
"name": "docker-registry-ui",
"version": "1.4.8",
"productName": "Registry UI",
"description": "Electron Application for Docker Registry UI",
"main": "index.js",
"scripts": {
"start": "electron ./",
"start:dev": "parcel serve -d dist/authentication -t electron --public-url ./ authentication/index.html",
"build": "parcel build -d dist/authentication -t electron --public-url ./ authentication/index.html",
"rebuild": "electron-rebuild -f -w keytar",
"package": "electron-packager --overwrite .",
"sync": "copyfiles ../../dist/* ../../dist/**/* ./examples/out",
"dist": "npm run rebuild && npm run sync && npm run build && npm run package"
},
"dependencies": {
"@material-ui/core": "^4.9.13",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.52",
"electron-is-dev": "^1.1.0",
"keytar": "^5.6.0",
"react": "^16.13.1",
"react-dom": "^16.13.1"
},
"devDependencies": {
"copyfiles": "^2.2.0",
"electron": "^8.0.0",
"electron-builder": "^22.6.0",
"electron-packager": "^14.2.1",
"electron-rebuild": "^1.10.1",
"parcel-bundler": "^1.12.4",
"typescript": "^3.8.3"
},
"keywords": [
"electron"
],
"author": "",
"license": "AGPL-3.0"
}

View File

@@ -1,8 +1,9 @@
{
"name": "docker-registry-ui",
"version": "1.4.8",
"version": "1.4.9",
"scripts": {
"build": "./node_modules/gulp/bin/gulp.js build"
"build": "./node_modules/gulp/bin/gulp.js build",
"build:electron": "npm run build && cd examples/electron && npm install && npm run dist"
},
"repository": {
"type": "git",

View File

@@ -17,6 +17,8 @@
var registryUI = {}
registryUI.URL_QUERY_PARAM_REGEX = /[&?]url=/;
registryUI.URL_PARAM_REGEX = /^url=/;
registryUI.showContentDigest = true;
registryUI.catalogElementsLimit = 100000;
registryUI.url = function(byPassQueryParam) {
if (!registryUI._url) {

View File

@@ -33,6 +33,8 @@ registryUI.name = function() {
};
registryUI.pullUrl = '${PULL_URL}';
registryUI.isImageRemoveActivated = true;
registryUI.showContentDigest = true;
registryUI.catalogElementsLimit = 100000;
registryUI.catalog = {};
registryUI.taglist = {};
registryUI.taghistory = {};

View File

@@ -446,6 +446,14 @@ image-content-digest {
padding: 7px 5px;
}
taglist .creation-date {
width: 10em;
}
taglist .image-size {
width: 7em;
}
catalog material-card,
tag-history material-card {
min-height: auto;

View File

@@ -68,7 +68,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
registryUI.catalog.loadend = true;
registryUI.catalog.instance.update();
});
oReq.open('GET', registryUI.url() + '/v2/_catalog?n=100000');
oReq.open('GET', registryUI.url() + '/v2/_catalog?n=' + registryUI.catalogElementsLimit);
oReq.send();
};
registryUI.catalog.display();

View File

@@ -28,7 +28,7 @@
if (chars >= 70) {
self.display_id = self.digest;
self.title = '';
} else if (chars === 0) {
} else if (chars <= 0) {
self.display_id = '';
self.title = self.digest;
} else {

View File

@@ -41,9 +41,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<table show="{ registryUI.taglist.loadend }" style="border: none;">
<thead>
<tr>
<th>Creation date</th>
<th>Size</th>
<th id="image-content-digest-header">Content Digest</th>
<th class="creation-date">Creation date</th>
<th class="image-size">Size</th>
<th id="image-content-digest-header" if="{ registryUI.showContentDigest }">Content Digest</th>
<th
id="image-tag-header"
@@ -60,13 +60,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</thead>
<tbody>
<tr each="{ image in this.opts.tags }">
<td>
<td class="creation-date">
<image-date image="{ image }"/>
</td>
<td>
<td class="image-size">
<image-size image="{ image }"/>
</td>
<td>
<td if="{ registryUI.showContentDigest }">
<image-content-digest image="{ image }"/>
<copy-to-clipboard target="digest" image={ image }/>
</td>
@@ -93,6 +93,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
// window.innerWidth is a blocking access, cache its result.
const innerWidth = window.innerWidth;
var chars = 0;
var max = registryUI.taglist.tags.reduce(function(acc, e) {
return e.tag.length > acc ? e.tag.length : acc;
}, 0);
if (innerWidth >= 1440) {
chars = 71;
} else if (innerWidth < 1024) {
@@ -101,6 +104,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
// SHA256:12345678 + scaled between 1024 and 1440px
chars = 15 + 56 * ((innerWidth - 1024) / 416);
}
if (max > 20) chars -= (max - 20);
registryUI.taglist.tags.map(function (image) {
image.trigger('content-digest-chars', chars);
});