atlas-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From venkat...@apache.org
Subject [09/11] incubator-atlas git commit: ATLAS-112 UI: Make lineage graph extensible for multiple nodes. Contributed by Vishal Kadam
Date Fri, 11 Sep 2015 22:52:46 GMT
ATLAS-112 UI: Make lineage graph extensible for multiple nodes. Contributed by Vishal Kadam


Project: http://git-wip-us.apache.org/repos/asf/incubator-atlas/repo
Commit: http://git-wip-us.apache.org/repos/asf/incubator-atlas/commit/1e1ed482
Tree: http://git-wip-us.apache.org/repos/asf/incubator-atlas/tree/1e1ed482
Diff: http://git-wip-us.apache.org/repos/asf/incubator-atlas/diff/1e1ed482

Branch: refs/heads/master
Commit: 1e1ed482cebff075664577d383d5e7a3bc6d12c3
Parents: ff5b1f1
Author: Venkatesh Seetharam <venkatesh@apache.org>
Authored: Fri Sep 11 15:16:37 2015 -0700
Committer: Venkatesh Seetharam <venkatesh@apache.org>
Committed: Fri Sep 11 15:16:37 2015 -0700

----------------------------------------------------------------------
 dashboard/.jshintrc                             |   5 +-
 .../public/modules/lineage/lineageController.js | 582 +++++++++++++++----
 .../public/modules/lineage/views/lineage.html   |  15 +-
 release-log.txt                                 |   2 +
 4 files changed, 482 insertions(+), 122 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/incubator-atlas/blob/1e1ed482/dashboard/.jshintrc
----------------------------------------------------------------------
diff --git a/dashboard/.jshintrc b/dashboard/.jshintrc
index 62b5e65..f42738e 100644
--- a/dashboard/.jshintrc
+++ b/dashboard/.jshintrc
@@ -39,7 +39,8 @@
     "undef": true, // Require all non-global variables be declared before they are used.
     "unused": true, // Warn unused variables.
     "globals": { // Globals variables.
-        "angular": true
+        "angular": true,
+        "$": false
     },
     "predef": [ // Extra globals.
         "define",
@@ -60,4 +61,4 @@
         "expect",
         "ngGridFlexibleHeightPlugin"
     ]
-}
\ No newline at end of file
+}

http://git-wip-us.apache.org/repos/asf/incubator-atlas/blob/1e1ed482/dashboard/public/modules/lineage/lineageController.js
----------------------------------------------------------------------
diff --git a/dashboard/public/modules/lineage/lineageController.js b/dashboard/public/modules/lineage/lineageController.js
index b8bd09c..151002f 100644
--- a/dashboard/public/modules/lineage/lineageController.js
+++ b/dashboard/public/modules/lineage/lineageController.js
@@ -36,8 +36,9 @@ angular.module('dgc.lineage').controller('LineageController', ['$element',
'$sco
                             render();
                         }
                     });
+                }else{ 
+                    $scope.requested = false;
                 }
-                $scope.requested = false;
             });
         }
 
@@ -62,16 +63,28 @@ angular.module('dgc.lineage').controller('LineageController', ['$element',
'$sco
 
         $scope.type = $element.parent().attr('data-table-type');
         $scope.requested = false;
+        $scope.height = $element[0].offsetHeight;
+        $scope.width = $element[0].offsetWidth;
 
         function render() {
             renderGraph($scope.lineageData, {
+                eleObj : $element,
                 element: $element[0],
-                height: $element[0].offsetHeight,
-                width: $element[0].offsetWidth
+                height: $scope.height,
+                width: $scope.width
             });
             $scope.rendered = true;
         }
 
+        $scope.onReset = function(){
+            renderGraph($scope.lineageData, {
+                eleObj : $element,
+                element: $element[0],
+                height: $scope.height,
+                width: $scope.width
+            }); 
+        };
+
         $scope.$on('render-lineage', function(event, lineageData) {
             if (lineageData.type === $scope.type) {
                 if (!$scope.lineageData) {
@@ -155,50 +168,249 @@ angular.module('dgc.lineage').controller('LineageController', ['$element',
'$sco
         }
 
         function renderGraph(data, container) {
-            // ************** Generate the tree diagram	 *****************
-            var element = d3.select(container.element),
-                width = Math.max(container.width, 960),
-                height = Math.max(container.height, 350);
-
-            var margin = {
-                top: 100,
-                right: 80,
-                bottom: 30,
-                left: 80
-            };
-            width = width - margin.right - margin.left;
-            height = height - margin.top - margin.bottom;
-
-            var i = 0;
-
-            var tree = d3.layout.tree()
-                .size([height, width]);
-
-            var diagonal = d3.svg.diagonal()
-                .projection(function(d) {
-                    return [d.y, d.x];
-                });
+            // ************** Generate the tree diagram  *****************
+            var element = d3.select(container.element),  
+                widthg = Math.max(container.width, 960),
+                heightg = Math.max(container.height, 500),
+
+                totalNodes = 0,
+                maxLabelLength = 0,
+                selectedNode = null,
+                draggingNode = null,
+                dragListener = null,
+                dragStarted = true,
+                domNode = null,
+                multiParents = null,
+                nodes = null,
+                tooltip = null,
+                node = null,  
+                i = 0,
+                duration = 750,
+                root,
+                depthwidth = 10;
+                
+
+            var viewerWidth = widthg - 15,
+                viewerHeight = heightg;
+ 
+             var tree = d3.layout.tree().nodeSize([100, 200]);
+                /*.size([viewerHeight, viewerWidth]);*/
+
+    container.eleObj.find(".graph").html('');    
+    container.eleObj.find("svg").remove();
+
+    // define a d3 diagonal projection for use by the node paths later on.
+    var diagonal = d3.svg.diagonal()
+        .projection(function(d) {
+            return [d.y, d.x];
+        });
 
-            /* Initialize tooltip */
-            var tooltip = d3.tip()
-                .attr('class', 'd3-tip')
-                .html(function(d) {
-                    return '<pre class="alert alert-success">' + d.tip + '</pre>';
-                });
+    // A recursive helper function for performing some setup by walking through all nodes
+
+    function visit(parent, visitFn, childrenFn) {
+        if (!parent) return;
+
+        visitFn(parent);
+
+        var children = childrenFn(parent);
+        if (children) {
+            var count = children.length;
+            for (var i = 0; i < count; i++) {
+                visit(children[i], visitFn, childrenFn);
+            }
+        }
+    }
+
+    // Call visit function to establish maxLabelLength
+    visit(data, function(d) {
+        totalNodes++;
+        maxLabelLength = Math.max(d.name.length, maxLabelLength);
+
+    }, function(d) {
+        return d.children && d.children.length > 0 ? d.children : null;
+    });
+
+
+    // sort the tree according to the node names
+
+    function sortTree() {
+        tree.sort(function(a, b) {
+            return b.name.toLowerCase() < a.name.toLowerCase() ? 1 : -1;
+        });
+    }
+    // Sort the tree initially incase the JSON isn't in a sorted order.
+    sortTree(); 
+     
+    // Define the zoom function for the zoomable tree
+
+    function zoom() {
+        svgGroup.attr("transform", "translate(" + d3.event.translate + ")scale(" + d3.event.scale
+ ")");
+    }
+
+
+    // define the zoomListener which calls the zoom function on the "zoom" event constrained
within the scaleExtents
+    var zoomListener = d3.behavior.zoom().scaleExtent([0.1, 3]).on("zoom", zoom);
+     /* Initialize tooltip */
+    tooltip = d3.tip()
+        .attr('class', 'd3-tip')
+        .html(function(d) {
+            return '<pre class="alert alert-success">' + d.name + '</pre>';
+        });
 
-            var svg = element.select('svg')
-                .attr('width', width + margin.right + margin.left)
-                .attr('height', height + margin.top + margin.bottom)
-                /* Invoke the tip in the context of your visualization */
-                .call(tooltip)
-                .select('g')
-                .attr('transform',
-                    'translate(' + margin.left + ',' + margin.right + ')');
-            //arrow
-            svg.append("svg:defs").append("svg:marker").attr("id", "arrow").attr("viewBox",
"0 0 10 10").attr("refX", 26).attr("refY", 5).attr("markerUnits", "strokeWidth").attr("markerWidth",
6).attr("markerHeight", 9).attr("orient", "auto").append("svg:path").attr("d", "M 0 0 L 10
5 L 0 10 z");
+    // define the baseSvg, attaching a class for styling and the zoomListener
+    var baseSvg = element.append('svg')        
+        .attr("width", viewerWidth)
+        .attr("height", viewerHeight)
+        .attr("class", "overlay")
+        .call(zoomListener)
+        .call(tooltip);
+
+
+    // Define the drag listeners for drag/drop behaviour of nodes.
+    dragListener = d3.behavior.drag()
+        .on("dragstart", function(d) {
+            if (d ===root) {
+                return;
+            }
+            dragStarted = true;
+            nodes = tree.nodes(d);
+            d3.event.sourceEvent.stopPropagation();
+            // it's important that we suppress the mouseover event on the node being dragged.
Otherwise it will absorb the mouseover event and the underlying node will not detect it d3.select(this).attr('pointer-events',
'none');
+        }) 
+        .on("dragend", function(d) {
+            if (d ===root) {
+                return;
+            }
+            domNode = this;
+            if (selectedNode) {
+                // now remove the element from the parent, and insert it into the new elements
children
+                var index = draggingNode.parent.children.indexOf(draggingNode);
+                if (index > -1) {
+                    draggingNode.parent.children.splice(index, 1);
+                }
+                if (typeof selectedNode.children !== 'undefined' || typeof selectedNode._children
!== 'undefined') {
+                    if (typeof selectedNode.children !== 'undefined') {
+                        selectedNode.children.push(draggingNode);
+                    } else {
+                        selectedNode._children.push(draggingNode);
+                    }
+                } else {
+                    selectedNode.children = [];
+                    selectedNode.children.push(draggingNode);
+                }
+                // Make sure that the node being added to is expanded so user can see added
node is correctly moved
+                expand(selectedNode);
+                sortTree();
+                endDrag();
+            } else {
+                endDrag();
+            }
+        });
+
+    function endDrag() {
+        selectedNode = null;
+        d3.selectAll('.ghostCircle').attr('class', 'ghostCircle');
+        d3.select(domNode).attr('class', 'node');
+        // now restore the mouseover event or we won't be able to drag a 2nd time
+        d3.select(domNode).select('.ghostCircle').attr('pointer-events', '');
+        updateTempConnector();
+        if (draggingNode !== null) {
+            update(root);
+            centerNode(draggingNode);
+            draggingNode = null;
+        }
+    }
+
+
+    function expand(d) {
+        if (d._children) {
+            d.children = d._children;
+            d.children.forEach(expand);
+            d._children = null;
+        }
+    } 
+
+    // Function to update the temporary connector indicating dragging affiliation
+    var updateTempConnector = function() {
+        var data = [];
+        if (draggingNode !== null && selectedNode !== null) {
+            // have to flip the source coordinates since we did this for the existing connectors
on the original tree
+            data = [{
+                source: {
+                    x: selectedNode.y0,
+                    y: selectedNode.x0
+                },
+                target: {
+                    x: draggingNode.y0,
+                    y: draggingNode.x0
+                }
+            }];
+        }
+        var link = svgGroup.selectAll(".templink").data(data);
+
+        link.enter().append("path")
+            .attr("class", "templink")
+            .attr("d", d3.svg.diagonal())
+            .attr('pointer-events', 'none');
+
+        link.attr("d", d3.svg.diagonal());
+
+        link.exit().remove();
+    };
+
+    // Function to center node when clicked/dropped so node doesn't get lost when collapsing/moving
with large amount of children.
+
+    function centerNode(source) {
+        var scale =  (depthwidth === 10) ? zoomListener.scale() : 0.4;
+        var x = -source.y0;
+        var y = -source.x0;
+        x = x * scale + 150;
+        y = y * scale + viewerHeight / 2;
+        d3.select('g').transition()
+            .duration(duration)
+            .attr("transform", "translate(" + x + "," + y + ")scale(" + scale + ")");
+        zoomListener.scale(scale);
+        zoomListener.translate([x, y]);
+    }
+
+    // Toggle children function
+
+    function toggleChildren(d) {
+        if (d.children) {
+            d._children = d.children;
+            d.children = null;
+        } else if (d._children) {
+            d.children = d._children;
+            d._children = null;
+        }
+        return d;
+    }
+
+    // Toggle children on click.
+
+    function click(d) {
+        if (d3.event.defaultPrevented) return; // click suppressed
+        d = toggleChildren(d);
+        update(d);
+        //centerNode(d);
+    }
+
+    //arrow
+            baseSvg.append("svg:defs")
+                .append("svg:marker")
+                .attr("id", "arrow")
+                .attr("viewBox", "0 0 10 10")
+                .attr("refX", 22)
+                .attr("refY", 5)
+                .attr("markerUnits", "strokeWidth")
+                .attr("markerWidth", 6)
+                .attr("markerHeight", 9)
+                .attr("orient", "auto")
+                .append("svg:path")
+                .attr("d", "M 0 0 L 10 5 L 0 10 z");
 
             //marker for input type graph
-            svg.append("svg:defs")
+            baseSvg.append("svg:defs")
                 .append("svg:marker")
                 .attr("id", "input-arrow")
                 .attr("viewBox", "0 0 10 10")
@@ -211,88 +423,228 @@ angular.module('dgc.lineage').controller('LineageController', ['$element',
'$sco
                 .append("svg:path")
                 .attr("d", "M -2 5 L 8 0 L 8 10 z");
 
-            var root = data;
-
-            function update(source) {
+    function update(source) {
+        // Compute the new height, function counts total children of root node and sets tree
height accordingly.
+        // This prevents the layout looking squashed when new nodes are made visible or looking
sparse when nodes are removed
+        // This makes the layout more consistent.
+        var levelWidth = [1];
+        var childCount = function(level, n) {
 
-                // Compute the new tree layout.
-                var nodes = tree.nodes(source).reverse(),
-                    links = tree.links(nodes);
+            if (n.children && n.children.length > 0) {
+                if (levelWidth.length <= level + 1) levelWidth.push(0);
 
-                // Normalize for fixed-depth.
-                nodes.forEach(function(d) {
-                    d.y = d.depth * 180;
+                levelWidth[level + 1] += n.children.length;
+                n.children.forEach(function(d) {
+                    childCount(level + 1, d);
                 });
+            }
+        };
+        childCount(0, root);
+        tree = tree.nodeSize([50, 100]); 
+
+        // Compute the new tree layout.
+        var nodes = tree.nodes(root).reverse(),
+            links = tree.links(nodes);
+
+        // Set widths between levels based on maxLabelLength.
+        nodes.forEach(function(d) {
+            if(levelWidth.length > 1 && depthwidth === 10){
+               for(var o=0; o < levelWidth.length; o++){
+                  if(levelWidth[o] > 4 ) { depthwidth = 70;  break;}
+               }
+            }  
+            var maxLebal = maxLabelLength;
+            if(depthwidth === 10) { maxLebal = 20;}
+            d.y = (d.depth * (maxLebal * depthwidth));           
+        }); 
+
+        // Update the nodes…
+        node = svgGroup.selectAll("g.node")
+            .data(nodes, function(d) {
+                return d.id || (d.id = ++i);
+            });
 
-                // Declare the nodes…
-                var node = svg.selectAll('g.node')
-                    .data(nodes, function(d) {
-                        return d.id || (d.id = ++i);
-                    });
+        // Enter any new nodes at the parent's previous position.
+        var nodeEnter = node.enter().append("g")
+            .call(dragListener)
+            .attr("class", "node")
+            .attr("transform", function() {
+                return "translate(" + source.y0 + "," + source.x0 + ")";
+            })
+            .on('click', click);
+ 
+         nodeEnter.append("image")
+            .attr("class","nodeImage")
+            .attr("xlink:href", function(d) { 
+                return d.type === 'Table' ? '../img/tableicon.png' : '../img/process.png';
+            })
+            .on('mouseover', function(d) {
+                if (d.type === 'LoadProcess' || 'Table') {
+                    tooltip.show(d);
+                }
+            })
+            .on('mouseout', function(d) {
+                if (d.type === 'LoadProcess' || 'Table') {
+                    tooltip.hide(d);
+                }
+            })
+            .attr("x", "-18px")
+            .attr("y", "-18px")
+            .attr("width", "34px")
+            .attr("height", "34px");    
+
+        nodeEnter.append("text")
+            .attr("x", function(d) {
+                return d.children || d._children ? -10 : 10;
+            })
+            .attr("dx", function (d) { return d.children ? 50 : -50; })
+            .attr("dy", -24) 
+            .attr('class', 'place-label')
+            .attr("text-anchor", function(d) {
+                return d.children || d._children ? "end" : "start";
+            })
+            .text(function(d) {
+                var nameDis = (d.name.length > 15) ? d.name.substring(0,15) + "..." :
d.name;
+                $(this).attr('title', d.name);
+                return nameDis;
+            })
+            .style("fill-opacity", 0); 
+
+        // Update the text to reflect whether node has children or not.
+        node.select('text')
+            .attr("x", function(d) {
+                return d.children || d._children ? -10 : 10;
+            })
+            .attr("text-anchor", function(d) {
+                return d.children || d._children ? "end" : "start";
+            })
+            .text(function(d) {
+                var nameDis = (d.name.length > 15) ? d.name.substring(0,15) + "..." :
d.name;
+                $(this).attr('title', d.name);
+                return nameDis;
+            });
 
-                // Enter the nodes.
-                var nodeEnter = node.enter().append('g')
-                    .attr('class', 'node')
-                    .attr('transform', function(d) {
-                        return 'translate(' + d.y + ',' + d.x + ')';
-                    });
+        // Change the circle fill depending on whether it has children and is collapsed
+        // Change the circle fill depending on whether it has children and is collapsed
+        node.select("image.nodeImage")
+            .attr("r", 4.5)
+            .attr("xlink:href", function(d) { 
+                if(d._children){
+                    return d.type === 'Table' ? '../img/tableicon1.png' : '../img/process1.png';
+                }
+                return d.type === 'Table' ? '../img/tableicon.png' : '../img/process.png';
+            });
 
-                nodeEnter.append("image")
-                    .attr("xlink:href", function(d) {
-                        //return d.icon;
-                        return d.type === 'Table' ? '../img/tableicon.png' : '../img/process.png';
-                    })
-                    .on('mouseover', function(d) {
-                        if (d.type === 'LoadProcess') {
-                            tooltip.show(d);
-                        }
-                    })
-                    .on('mouseout', function(d) {
-                        if (d.type === 'LoadProcess') {
-                            tooltip.hide(d);
-                        }
-                    })
-                    .attr("x", "-18px")
-                    .attr("y", "-18px")
-                    .attr("width", "34px")
-                    .attr("height", "34px");
-
-                nodeEnter.append('text')
-                    .attr('x', function(d) {
-                        return d.children || d._children ?
-                            (5) * -1 : +15;
-                    })
-                    .attr('dy', '-1.75em')
-                    .attr('text-anchor', function(d) {
-                        return d.children || d._children ? 'middle' : 'middle';
-                    })
-                    .text(function(d) {
-                        return d.name;
-                    })
-
-                .style('fill-opacity', 1);
-
-                // Declare the links…
-                var link = svg.selectAll('path.link')
-                    .data(links, function(d) {
-                        return d.target.id;
-                    });
 
-                link.enter().insert('path', 'g')
-                    .attr('class', 'link')
-                    //.style('stroke', function(d) { return d.target.level; })
-                    .style('stroke', 'green')
-                    .attr('d', diagonal);
+        // Transition nodes to their new position.
+        var nodeUpdate = node.transition()
+            .duration(duration)
+            .attr("transform", function(d) {
+                return "translate(" + d.y + "," + d.x  + ")";
+            });
 
-                if ($scope.type === 'inputs') {
-                    link.attr("marker-start", "url(#input-arrow)"); //if input
-                } else {
-                    link.attr("marker-end", "url(#arrow)"); //if input
-                }
+        // Fade the text in
+        nodeUpdate.select("text")
+            .style("fill-opacity", 1);
 
-            }
+        // Transition exiting nodes to the parent's new position.
+        var nodeExit = node.exit().transition()
+            .duration(duration)
+            .attr("transform", function() {
+                return "translate(" + source.y + "," + source.x + ")";
+            })
+            .remove();
 
-            update(root);
+        nodeExit.select("circle")
+            .attr("r", 0);
+
+        nodeExit.select("text")
+            .style("fill-opacity", 0);
+
+        // Update the links…
+        var link = svgGroup.selectAll("path.link")
+            .data(links, function(d) {
+                return d.target.id;
+            });
+
+        // Enter any new links at the parent's previous position.
+        link.enter().insert("path", "g")
+            .attr("class", "link")
+            .style('stroke', 'green') 
+            .attr("d", function() {
+                var o = {
+                    x: source.x0,
+                    y: source.y0
+                };
+                return diagonal({
+                    source: o,
+                    target: o
+                });
+            });
+
+        // Transition links to their new position.
+        link.transition()
+            .duration(duration)
+            .attr("d", diagonal);
+
+        // Transition exiting nodes to the parent's new position.
+        link.exit().transition()
+            .duration(duration)
+            .attr("d", function() {
+                var o = {
+                    x: source.x,
+                    y: source.y
+                };
+                return diagonal({
+                    source: o,
+                    target: o
+                });
+            })
+            .remove();
+
+        // Stash the old positions for transition.
+        nodes.forEach(function(d) {
+            d.x0 = d.x;
+            d.y0 = d.y;
+        });
+
+        if ($scope.type === 'inputs') {
+            link.attr("marker-start", "url(#input-arrow)"); //if input
+        }  else {
+            link.attr("marker-end", "url(#arrow)"); //if input
+        }
+    }
+
+    // Append a group which holds all nodes and which the zoom Listener can act upon.
+    var svgGroup = baseSvg.append("g")
+                    .attr("transform", "translate(120 ," + heightg/2 + ")");
+
+    // Define the root
+    root = data;
+    root.x0 = viewerHeight / 2;
+    root.y0 = 0;
+
+    // Layout the tree initially and center on the root node.
+    update(root);
+    centerNode(root);
+    
+    var couplingParent1 = tree.nodes(root).filter(function(d) {
+            return d.name === 'cluster';
+        })[0];
+    var couplingChild1 = tree.nodes(root).filter(function(d) {
+            return d.name === 'JSONConverter';
+        })[0];
+    
+    multiParents = [{
+                    parent: couplingParent1,
+                    child: couplingChild1
+                }];
+    
+    multiParents.forEach(function() {
+            svgGroup.append("path", "g"); 
+        }); 
+
+           
         }
 
     }

http://git-wip-us.apache.org/repos/asf/incubator-atlas/blob/1e1ed482/dashboard/public/modules/lineage/views/lineage.html
----------------------------------------------------------------------
diff --git a/dashboard/public/modules/lineage/views/lineage.html b/dashboard/public/modules/lineage/views/lineage.html
index e2e9ebd..3b412f7 100644
--- a/dashboard/public/modules/lineage/views/lineage.html
+++ b/dashboard/public/modules/lineage/views/lineage.html
@@ -17,9 +17,14 @@
   -->
 
 <div class="lineage-viz" data-ng-controller="LineageController">
-    <h4 data-ng-if="!requested && !lineageData">No lineage data found</h4>
-    <i data-ng-if="requested" class="fa fa-spinner fa-spin fa-5x"></i>
-    <svg >
-        <g/>
-    </svg>
+    <button type="button" class="btn btn-primary pull-right" ng-click="onReset()">
+        Reset
+    </button>
+    <div class="graph">
+      <h4 data-ng-if="!requested && !lineageData">No lineage data found</h4>
+      <i data-ng-if="requested" class="fa fa-spinner fa-spin fa-5x"></i>
+      <svg >
+          <g/>
+      </svg>
+    </div>
 </div>

http://git-wip-us.apache.org/repos/asf/incubator-atlas/blob/1e1ed482/release-log.txt
----------------------------------------------------------------------
diff --git a/release-log.txt b/release-log.txt
index dd50765..26e37d7 100644
--- a/release-log.txt
+++ b/release-log.txt
@@ -8,6 +8,8 @@ ATLAS-54 Rename configs in hive hook (shwethags)
 ATLAS-3 Mixed Index creation fails with Date types (suma.shivaprasad via shwethags)
 
 ALL CHANGES:
+ATLAS-112 UI: Make lineage graph extensible for multiple nodes (Vishal Kadam
+via Venkatesh Seetharam)
 ATLAS-152 TimeStamp fields not showing the details tab (Vishal Kadam
 via Venkatesh Seetharam)
 ATLAS-111 UI: Create Help Link (Vishal Kadam via Venkatesh Seetharam)


Mime
View raw message