[release] v0.12.0-unstable42
This commit is contained in:
parent
aa963bb89f
commit
34bc76dbf8
|
@ -8,6 +8,7 @@
|
||||||
- Added Button to force reset HTTPS cert in settings
|
- Added Button to force reset HTTPS cert in settings
|
||||||
- New color slider with reset buttons
|
- New color slider with reset buttons
|
||||||
- Added a notification when updating a container
|
- Added a notification when updating a container
|
||||||
|
- Improved icon loading speed, and added proper placeholder
|
||||||
- Added lazyloading to URL and Servapp pages images
|
- Added lazyloading to URL and Servapp pages images
|
||||||
- Added a dangerous IP detector that stops sending HTTP response to IPs that are abusing various shields features
|
- Added a dangerous IP detector that stops sending HTTP response to IPs that are abusing various shields features
|
||||||
- Added a button in the servapp page to easily download the docker backup
|
- Added a button in the servapp page to easily download the docker backup
|
||||||
|
|
48
client/src/components/imageWithPlaceholder.jsx
Normal file
48
client/src/components/imageWithPlaceholder.jsx
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import LazyLoad from 'react-lazyload';
|
||||||
|
import cosmosGray from '../assets/images/icons/cosmos_gray.png';
|
||||||
|
|
||||||
|
function ImageWithPlaceholder({ src, alt, placeholder, ...props }) {
|
||||||
|
const [imageSrc, setImageSrc] = useState(placeholder || cosmosGray);
|
||||||
|
const [imageRef, setImageRef] = useState();
|
||||||
|
|
||||||
|
const onLoad = event => {
|
||||||
|
event.target.classList.add('loaded');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = () => {
|
||||||
|
setImageSrc(cosmosGray);
|
||||||
|
};
|
||||||
|
|
||||||
|
// This function will be called when the actual image is loaded
|
||||||
|
const handleImageLoad = () => {
|
||||||
|
if (imageRef) {
|
||||||
|
imageRef.src = src;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
ref={setImageRef}
|
||||||
|
{...props}
|
||||||
|
src={imageSrc}
|
||||||
|
alt={alt}
|
||||||
|
onLoad={onLoad}
|
||||||
|
onError={onError}
|
||||||
|
// style={{ opacity: imageSrc === src ? 1 : 0, transition: 'opacity 0.5s ease-in-out' }}
|
||||||
|
/>
|
||||||
|
{/* This image will load the actual image and then handleImageLoad will be triggered */}
|
||||||
|
<img
|
||||||
|
{...props}
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
style={{ display: 'none' }} // Hide this image
|
||||||
|
onLoad={handleImageLoad}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ImageWithPlaceholder;
|
|
@ -64,18 +64,22 @@ const Notification = () => {
|
||||||
const setAsRead = () => {
|
const setAsRead = () => {
|
||||||
let unread = [];
|
let unread = [];
|
||||||
|
|
||||||
let newN = notifications.map((notif) => {
|
notifications.forEach((notif) => {
|
||||||
if (!notif.Read) {
|
if (!notif.Read) {
|
||||||
unread.push(notif.ID);
|
unread.push(notif.ID);
|
||||||
}
|
}
|
||||||
notif.Read = true;
|
|
||||||
return notif;
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (unread.length > 0) {
|
if (unread.length > 0) {
|
||||||
API.users.readNotifs(unread);
|
API.users.readNotifs(unread);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setLocalAsRead = (id) => {
|
||||||
|
let newN = notifications.map((notif) => {
|
||||||
|
notif.Read = true;
|
||||||
|
return notif;
|
||||||
|
})
|
||||||
setNotifications(newN);
|
setNotifications(newN);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -104,6 +108,7 @@ const Notification = () => {
|
||||||
if (anchorRef.current && anchorRef.current.contains(event.target)) {
|
if (anchorRef.current && anchorRef.current.contains(event.target)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setLocalAsRead();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { redirectToLocal } from '../../../utils/indexs';
|
||||||
import { CosmosCheckbox } from '../users/formShortcuts';
|
import { CosmosCheckbox } from '../users/formShortcuts';
|
||||||
import { Field } from 'formik';
|
import { Field } from 'formik';
|
||||||
import MiniPlotComponent from '../../dashboard/components/mini-plot';
|
import MiniPlotComponent from '../../dashboard/components/mini-plot';
|
||||||
|
import ImageWithPlaceholder from '../../../components/imageWithPlaceholder';
|
||||||
|
|
||||||
const info = {
|
const info = {
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||||
|
@ -39,7 +40,7 @@ const RouteOverview = ({ routeConfig }) => {
|
||||||
</div>}>
|
</div>}>
|
||||||
<Stack spacing={2} direction={isMobile ? 'column' : 'row'} alignItems={isMobile ? 'center' : 'flex-start'}>
|
<Stack spacing={2} direction={isMobile ? 'column' : 'row'} alignItems={isMobile ? 'center' : 'flex-start'}>
|
||||||
<div>
|
<div>
|
||||||
<img className="loading-image" alt="" src={getFaviconURL(routeConfig)} width="128px" />
|
<ImageWithPlaceholder className="loading-image" alt="" src={getFaviconURL(routeConfig)} width="128px" />
|
||||||
</div>
|
</div>
|
||||||
<Stack spacing={2} style={{ width: '100%' }}>
|
<Stack spacing={2} style={{ width: '100%' }}>
|
||||||
<strong><ContainerOutlined />Description</strong>
|
<strong><ContainerOutlined />Description</strong>
|
||||||
|
|
|
@ -41,6 +41,7 @@ import { useNavigate } from 'react-router';
|
||||||
import NewRouteCreate from '../routes/newRoute';
|
import NewRouteCreate from '../routes/newRoute';
|
||||||
import LazyLoad from 'react-lazyload';
|
import LazyLoad from 'react-lazyload';
|
||||||
import MiniPlotComponent from '../../dashboard/components/mini-plot';
|
import MiniPlotComponent from '../../dashboard/components/mini-plot';
|
||||||
|
import ImageWithPlaceholder from '../../../components/imageWithPlaceholder';
|
||||||
|
|
||||||
const stickyButton = {
|
const stickyButton = {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
|
@ -166,7 +167,7 @@ const ProxyManagement = () => {
|
||||||
{
|
{
|
||||||
title: '',
|
title: '',
|
||||||
field: (r) => <LazyLoad width={"64px"} height={"64px"}>
|
field: (r) => <LazyLoad width={"64px"} height={"64px"}>
|
||||||
<img className="loading-image" alt="" src={getFaviconURL(r)} width="64px" height="64px"/>
|
<ImageWithPlaceholder className="loading-image" alt="" src={getFaviconURL(r)} width="64px" height="64px"/>
|
||||||
</LazyLoad>,
|
</LazyLoad>,
|
||||||
style: {
|
style: {
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
|
|
|
@ -39,7 +39,7 @@ const _MiniPlotComponent = ({metrics, labels, noLabels, noBackground, agglo, tit
|
||||||
const [ref, inView] = useInView();
|
const [ref, inView] = useInView();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if(!inView) return;
|
if(!inView || series.length) return;
|
||||||
|
|
||||||
let xAxis = [];
|
let xAxis = [];
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
import { getFaviconURL } from "./routes";
|
import { getFaviconURL } from "./routes";
|
||||||
import logogray from '../assets/images/icons/cosmos_gray.png';
|
import logogray from '../assets/images/icons/cosmos_gray.png';
|
||||||
import LazyLoad from 'react-lazyload';
|
import LazyLoad from 'react-lazyload';
|
||||||
|
import ImageWithPlaceholder from "../components/imageWithPlaceholder";
|
||||||
|
|
||||||
export const ServAppIcon = ({route, container, width, ...pprops}) => {
|
export const ServAppIcon = ({route, container, width, ...pprops}) => {
|
||||||
return <LazyLoad width={width} height={width}>
|
return <LazyLoad width={width} height={width}>
|
||||||
{(container && container.Labels["cosmos-icon"]) ?
|
{(container && container.Labels["cosmos-icon"]) ?
|
||||||
<img src={container.Labels["cosmos-icon"]} {...pprops} width={width} height={width}></img> :(
|
<ImageWithPlaceholder src={container.Labels["cosmos-icon"]} {...pprops} width={width} height={width}></ImageWithPlaceholder> :(
|
||||||
route ? <img src={getFaviconURL(route)} {...pprops} width={width} height={width}></img>
|
route ? <ImageWithPlaceholder src={getFaviconURL(route)} {...pprops} width={width} height={width}></ImageWithPlaceholder>
|
||||||
: <img src={logogray} {...pprops} width={width} height={width}></img>)}
|
: <ImageWithPlaceholder src={logogray} {...pprops} width={width} height={width}></ImageWithPlaceholder>)}
|
||||||
</LazyLoad>;
|
</LazyLoad>;
|
||||||
};
|
};
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "cosmos-server",
|
"name": "cosmos-server",
|
||||||
"version": "0.12.0-unstable41",
|
"version": "0.12.0-unstable42",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "test-server.js",
|
"main": "test-server.js",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
|
|
|
@ -284,7 +284,10 @@ func InitServer() *mux.Router {
|
||||||
router.Use(utils.BlockByCountryMiddleware(config.BlockedCountries, config.CountryBlacklistIsWhitelist))
|
router.Use(utils.BlockByCountryMiddleware(config.BlockedCountries, config.CountryBlacklistIsWhitelist))
|
||||||
}
|
}
|
||||||
|
|
||||||
router.HandleFunc("/logo", SendLogo)
|
logoAPI := router.PathPrefix("/logo").Subrouter()
|
||||||
|
SecureAPI(logoAPI, true)
|
||||||
|
logoAPI.HandleFunc("/", SendLogo)
|
||||||
|
|
||||||
|
|
||||||
srapi := router.PathPrefix("/cosmos").Subrouter()
|
srapi := router.PathPrefix("/cosmos").Subrouter()
|
||||||
|
|
||||||
|
|
170
src/icons.go
170
src/icons.go
|
@ -11,6 +11,7 @@ import (
|
||||||
"path"
|
"path"
|
||||||
"time"
|
"time"
|
||||||
"context"
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"go.deanishe.net/favicon"
|
"go.deanishe.net/favicon"
|
||||||
|
|
||||||
|
@ -69,7 +70,11 @@ func sendFallback(w http.ResponseWriter) {
|
||||||
}
|
}
|
||||||
|
|
||||||
var IconCacheLock = make(chan bool, 1)
|
var IconCacheLock = make(chan bool, 1)
|
||||||
|
type result struct {
|
||||||
|
IconURL string
|
||||||
|
CachedImage CachedImage
|
||||||
|
Error error
|
||||||
|
}
|
||||||
func GetFavicon(w http.ResponseWriter, req *http.Request) {
|
func GetFavicon(w http.ResponseWriter, req *http.Request) {
|
||||||
if utils.LoggedInOnly(w, req) != nil {
|
if utils.LoggedInOnly(w, req) != nil {
|
||||||
return
|
return
|
||||||
|
@ -89,29 +94,26 @@ func GetFavicon(w http.ResponseWriter, req *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if(req.Method == "GET") {
|
if req.Method == "GET" {
|
||||||
utils.Log("Fetch favicon for " + siteurl)
|
utils.Log("Fetch favicon for " + siteurl)
|
||||||
|
|
||||||
// Check if we have the favicon in cache
|
// Check if we have the favicon in cache
|
||||||
if _, ok := IconCache[siteurl]; ok {
|
if resp, ok := IconCache[siteurl]; ok {
|
||||||
utils.Debug("Favicon in cache")
|
utils.Debug("Favicon in cache")
|
||||||
resp := IconCache[siteurl]
|
|
||||||
sendImage(w, resp)
|
sendImage(w, resp)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var icons []*favicon.Icon
|
var icons []*favicon.Icon
|
||||||
var defaultIcons = []*favicon.Icon{
|
var defaultIcons = []*favicon.Icon{
|
||||||
&favicon.Icon{URL: "favicon.png", Width: 0},
|
{URL: "/favicon.ico", Width: 0},
|
||||||
&favicon.Icon{URL: "/favicon.png", Width: 0},
|
{URL: "/favicon.png", Width: 0},
|
||||||
&favicon.Icon{URL: "favicon.ico", Width: 0},
|
{URL: "favicon.ico", Width: 0},
|
||||||
&favicon.Icon{URL: "/favicon.ico", Width: 0},
|
{URL: "favicon.png", Width: 0},
|
||||||
}
|
}
|
||||||
|
|
||||||
// follow siteurl and check if any redirect.
|
// follow siteurl and check if any redirect.
|
||||||
|
|
||||||
respNew, err := httpGetWithTimeout(siteurl)
|
respNew, err := httpGetWithTimeout(siteurl)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.Error("FaviconFetch", err)
|
utils.Error("FaviconFetch", err)
|
||||||
icons = append(icons, defaultIcons...)
|
icons = append(icons, defaultIcons...)
|
||||||
|
@ -138,74 +140,35 @@ func GetFavicon(w http.ResponseWriter, req *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a channel to collect favicon fetch results
|
||||||
|
resultsChan := make(chan result)
|
||||||
|
// Create a wait group to wait for all goroutines to finish
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// Loop through each icon and start a goroutine to fetch it
|
||||||
for _, icon := range icons {
|
for _, icon := range icons {
|
||||||
if icon.Width <= 256 {
|
if icon.Width <= 256 {
|
||||||
|
wg.Add(1)
|
||||||
iconURL := icon.URL
|
go func(icon *favicon.Icon) {
|
||||||
u, err := url.Parse(siteurl)
|
defer wg.Done()
|
||||||
if err != nil {
|
fetchAndCacheIcon(icon, siteurl, resultsChan)
|
||||||
utils.Debug("FaviconFetch failed to parse " + err.Error())
|
}(icon)
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasPrefix(iconURL, "http") {
|
|
||||||
if strings.HasPrefix(iconURL, ".") {
|
|
||||||
// Relative URL starting with "."
|
|
||||||
// Resolve the relative URL based on the base URL
|
|
||||||
baseURL := u.Scheme + "://" + u.Host
|
|
||||||
iconURL = baseURL + iconURL[1:]
|
|
||||||
} else if strings.HasPrefix(iconURL, "/") {
|
|
||||||
// Relative URL starting with "/"
|
|
||||||
// Append the relative URL to the base URL
|
|
||||||
iconURL = u.Scheme + "://" + u.Host + iconURL
|
|
||||||
} else {
|
|
||||||
// Relative URL without starting dot or slash
|
|
||||||
// Construct the absolute URL based on the current page's URL path
|
|
||||||
baseURL := u.Scheme + "://" + u.Host
|
|
||||||
baseURLPath := path.Dir(u.Path)
|
|
||||||
iconURL = baseURL + baseURLPath + "/" + iconURL
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.Debug("Favicon Trying to fetch " + iconURL)
|
// Close the results channel when all fetches are done
|
||||||
|
go func() {
|
||||||
// Fetch the favicon
|
wg.Wait()
|
||||||
resp, err := httpGetWithTimeout(iconURL)
|
close(resultsChan)
|
||||||
if err != nil {
|
}()
|
||||||
utils.Debug("FaviconFetch - " + err.Error())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if 200 and if image
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
utils.Debug("FaviconFetch - " + iconURL + " - not 200 ")
|
|
||||||
continue
|
|
||||||
} else if !strings.Contains(resp.Header.Get("Content-Type"), "image") && !strings.Contains(resp.Header.Get("Content-Type"), "octet-stream") {
|
|
||||||
utils.Debug("FaviconFetch - " + iconURL + " - not image ")
|
|
||||||
continue
|
|
||||||
} else {
|
|
||||||
utils.Log("Favicon found " + iconURL)
|
|
||||||
|
|
||||||
// Cache the response
|
|
||||||
body, err := ioutil.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
utils.Debug("FaviconFetch - cant read " + err.Error())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
finalImage := CachedImage{
|
|
||||||
ContentType: resp.Header.Get("Content-Type"),
|
|
||||||
ETag: resp.Header.Get("ETag"),
|
|
||||||
Body: body,
|
|
||||||
}
|
|
||||||
|
|
||||||
IconCache[siteurl] = finalImage
|
|
||||||
|
|
||||||
|
// Collect the results
|
||||||
|
for result := range resultsChan {
|
||||||
|
IconCache[siteurl] = result.CachedImage
|
||||||
sendImage(w, IconCache[siteurl])
|
sendImage(w, IconCache[siteurl])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
utils.Log("Favicon final fallback")
|
utils.Log("Favicon final fallback")
|
||||||
sendFallback(w)
|
sendFallback(w)
|
||||||
return
|
return
|
||||||
|
@ -217,6 +180,75 @@ func GetFavicon(w http.ResponseWriter, req *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetchAndCacheIcon is a helper function to fetch and cache the icon
|
||||||
|
func fetchAndCacheIcon(icon *favicon.Icon, baseSiteURL string, resultsChan chan<- result) {
|
||||||
|
iconURL := icon.URL
|
||||||
|
u, err := url.Parse(baseSiteURL)
|
||||||
|
if err != nil {
|
||||||
|
utils.Debug("FaviconFetch failed to parse " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(iconURL, "http") {
|
||||||
|
// Process the iconURL to make it absolute
|
||||||
|
iconURL = resolveIconURL(iconURL, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Debug("Favicon Trying to fetch " + iconURL)
|
||||||
|
|
||||||
|
// Fetch the favicon
|
||||||
|
resp, err := httpGetWithTimeout(iconURL)
|
||||||
|
if err != nil {
|
||||||
|
utils.Debug("FaviconFetch - " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Check if response is successful and content type is image
|
||||||
|
if resp.StatusCode != 200 || (!strings.Contains(resp.Header.Get("Content-Type"), "image") && !strings.Contains(resp.Header.Get("Content-Type"), "octet-stream")) {
|
||||||
|
utils.Debug("FaviconFetch - " + iconURL + " - not 200 or not image ")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := ioutil.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
utils.Debug("FaviconFetch - can't read " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare the cached image
|
||||||
|
cachedImage := CachedImage{
|
||||||
|
ContentType: resp.Header.Get("Content-Type"),
|
||||||
|
ETag: resp.Header.Get("ETag"),
|
||||||
|
Body: body,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the result back via the channel
|
||||||
|
resultsChan <- result{IconURL: iconURL, CachedImage: cachedImage}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveIconURL processes the iconURL to make it an absolute URL if it is relative
|
||||||
|
func resolveIconURL(iconURL string, baseURL *url.URL) string {
|
||||||
|
if strings.HasPrefix(iconURL, ".") {
|
||||||
|
// Relative URL starting with "."
|
||||||
|
// Resolve the relative URL based on the base URL
|
||||||
|
return baseURL.Scheme + "://" + baseURL.Host + iconURL[1:]
|
||||||
|
} else if strings.HasPrefix(iconURL, "/") {
|
||||||
|
// Relative URL starting with "/"
|
||||||
|
// Append the relative URL to the base URL
|
||||||
|
return baseURL.Scheme + "://" + baseURL.Host + iconURL
|
||||||
|
} else {
|
||||||
|
// Relative URL without starting dot or slash
|
||||||
|
// Construct the absolute URL based on the current page's URL path
|
||||||
|
baseURLPath := path.Dir(baseURL.Path)
|
||||||
|
if baseURLPath == "." {
|
||||||
|
baseURLPath = ""
|
||||||
|
}
|
||||||
|
return baseURL.Scheme + "://" + baseURL.Host + baseURLPath + "/" + iconURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
func PingURL(w http.ResponseWriter, req *http.Request) {
|
func PingURL(w http.ResponseWriter, req *http.Request) {
|
||||||
if utils.LoggedInOnly(w, req) != nil {
|
if utils.LoggedInOnly(w, req) != nil {
|
||||||
return
|
return
|
||||||
|
|
|
@ -115,6 +115,12 @@ func SaveMetrics() {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CheckAlerts(dp.Key, "latest", utils.AlertMetricTrack{
|
||||||
|
Key: dp.Key,
|
||||||
|
Object: dp.Object,
|
||||||
|
Max: dp.Max,
|
||||||
|
}, dp.Value)
|
||||||
|
|
||||||
// This ensures that if the document doesn't exist, it'll be created
|
// This ensures that if the document doesn't exist, it'll be created
|
||||||
options := options.Update().SetUpsert(true)
|
options := options.Update().SetUpsert(true)
|
||||||
|
|
||||||
|
@ -188,12 +194,6 @@ func PushSetMetric(key string, value int, def DataDef) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CheckAlerts(key, "latest", utils.AlertMetricTrack{
|
|
||||||
Key: key,
|
|
||||||
Object: def.Object,
|
|
||||||
Max: def.Max,
|
|
||||||
}, value)
|
|
||||||
|
|
||||||
lastInserted[key] = originalValue
|
lastInserted[key] = originalValue
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue