Ext JS 4: A File Browser with Spring 3 Server-side

I recently needed to create a file browser to access log files for my application. I don’t have access to the machines where the log files reside, so by creating a file browsing application I could ensure access controls and still get to the logs. From within my file browser I can open the log file in another browser tab/window or download the file. This was also my first time working with trees…

Here is what it looks like:
FileBrowser

And here is the code from top to bottom. Note, I’m using this to browse log files, hence every name says “LogViewer”.

Client Side
App.js

Ext.application({
	name: 'LogViewer',
	autoCreateViewport: true,

	appFolder: 'logviewer/app',

	views: [
		'Viewport'
	],
	controllers: [
		'Viewport'
	],

	launch : function () {
	}
});

app/model/File.js
I am using a TreePanel in the UI, and this model has the data fields it needs for my application. Since this will be in a tree store, the tree node interface fields will be automatically added to the model.

Ext.define('LogViewer.model.File', {
	extend: 'Ext.data.Model',
	fields: [
		'fileName','fileSize',
		{name:'fileDate',type: 'date', dateFormat: 'c'},
		'filePath'
	]
});

app/store/Files.js
Note this extends TreeStore, which implicitly adds the Ext.data.NodeInterface to my model defined above. This code is from my mockup, which is why the url goes to a json file. I ended up adding the reader to my proxy because I wanted the readRecordsOnFailure property to be false for error handling.

Ext.define('LogViewer.store.Files', {
	extend: 'Ext.data.TreeStore',
	requires: ['LogViewer.model.File'],
	model: 'LogViewer.model.File',
	autoLoad: true,
	defaultRootId: '',
	proxy : {
		type: 'ajax',
		url: './logviewer/data/files.json',
		reader: {
			type: 'json',
			readRecordsOnFailure: false,
			successProperty: 'success',
			messageProperty: 'message'
		}
	}
});

app/view/Viewport.js
My viewport is pretty straightforward. I am using my own dismissable alert ux for error handling and then have a simple Tree Panel with a few columns, including an action column. I also use a refresh tool so that the tree panel can be refreshed as needed. Trying to fit the action column handling into the MVC pattern wasn’t obvious. Mitchell Simoen’s blog post ActionColumn and MVC was very helpful.

Ext.define('LogViewer.view.Viewport', {
	extend: 'Ext.container.Viewport',

	requires : [
		'Ext.tree.Panel',
		'Ext.grid.column.Date',
		'Ext.ux.DismissableAlert'
	],

	layout: {
	    type: 'vbox',
	    align : 'stretch',
	    pack  : 'start'
	},

	initComponent : function () {
		this.items = this.buildItems();
		this.callParent(arguments);
	},

	buildItems : function () {
		return [
			{
				itemId: 'myalert',
				xtype: 'dismissalert',
				hidden: true,
				padding: '5 5 0 5'
			},
			{
				flex: 1,
				itemId: 'fileTree',
				title: 'Logs',
				xtype: 'treepanel',
				store: 'Files',
				autoScroll: true,
				rootVisible: false,
				userArrows: true,
				columns: [
					{xtype: 'treecolumn', text: 'Name', dataIndex: 'fileName', flex:2},
					{text: 'Size', dataIndex: 'fileSize', flex:1},
					{text: 'Last Modified', dataIndex: 'fileDate',  xtype: 'datecolumn', format:'Y-m-d\\TH:i:s', flex: 1},
					{xtype: 'actioncolumn', icon: '../images/icons/disk.png', tooltip: 'Download', getClass: this.actionItemRenderer}
				],
				tools:[{
					itemId: 'refreshTool',
				    type:'refresh',
				    tooltip: 'Reload from disk'
				}]
			}
		];
	},

	actionItemRenderer: function(value,meta,record,rowIx,ColIx, store) {
	    return record.isLeaf() ? 'x-grid-center-icon': 'x-hide-display';
	}
});

app/controller/Viewport.js
Last but not least, the controller. It’s a pretty standard controller. Figuring out how to add a listener to the store took a little research. Some of the other items are because I have to support older versions of IE. When opening the file in a new tab, the name of the tab/window can’t support special characters. So, I clean up the file name before opening the tab. And then there is downloading, I had various IE client-side downloading issues when trying to just use window.open, so I am using a hidden iframe to handle the downloads. The code to prepare the iframe is in the onDownload method.

Ext.define('LogViewer.controller.Viewport', {
	extend: 'Ext.app.Controller',

	requires: [
		'Ext.grid.column.Action'
	],

	stores: [
		'Files'
	],

	refs: [
		{ ref: 'fileTree', selector: '#fileTree' },
		{ ref: 'alertBox', selector: '#myalert'}
	],

	init : function () {
		Ext.log('init viewport controller');
		this.getFilesStore().getProxy().on({
	        exception: this.onProxyException,
	        scope: this
	    });
		this.control({
			'viewport #fileTree' : {
				itemclick : this.fileClick
			},
			'viewport tool[type="refresh"]' : {
				click: this.onRefresh
			},
			'viewport actioncolumn' : {
				click: this.handleActionColumn
			}
		});
	},

	fileClick : function(view, record, item, index, event) {
		Ext.log("in fileClick ");
		var logWin, fileName, cleanName, logUrl;

		if (record.isLeaf()) {
			fileName = record.get('filePath');
			logUrl = Ext.String.urlAppend('/logviewer/open', 'fileName='+fileName);
			cleanName = fileName.replace(/\W/g, '')
			Ext.log('open [' + logUrl + '] name [' + cleanName + ']');
		}
	},

	onRefresh : function() {
		Ext.log('in refresh');
		var fileTree = this.getFileTree();
		this.getAlertBox().hide();
		fileTree.getStore().load();
	},

	handleActionColumn : function(view, row, col, item, e, record) {
		var logWin, fileName, downloadUrl;
		Ext.log("handleActionColumn");
		if (record.isLeaf()) {
			fileName = record.get('filePath');
			this.onDownload(fileName);
		}
		return false;
	},

	onDownload : function(fileName) {
		Ext.log("Exporting to Excel");

		var frame, form, hidden, params, url;

		frame = Ext.fly('exportframe').dom;
		frame.src = Ext.SSL_SECURE_URL;

		form = Ext.fly('exportform').dom;
		url = '../util/htmlresponse.json';
		form.action = url;
		hidden = document.getElementById('excelconfig');
		params = {fileName: fileName};
		hidden.value = Ext.encode(fileName);

		form.submit();
	},

	onProxyException : function(proxy, response, operation) {
		var alertBox = this.getAlertBox();
		alertBox.showError("An unexpected error occurred.");
	}
});

And here is my html file

<!DOCTYPE html>
<html>
<head>
	<title>Log Viewer</title>
	<meta http-equiv="X-UA-Compatible" content="IE=9">
	<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
	<link rel="stylesheet" type="text/css" href="../../../extjs-4.1.1/resources/css/ext-all-debug.css">
    <link rel="stylesheet" type="text/css" href="../../../extjs-4.1.1/ux/css/DismissableAlert.css">

	<script type="text/javascript" src="../../../extjs-4.1.1/ext-dev.js"></script>
	<script type="text/javascript" src="logviewer/app.js"></script>
	<script>
		Ext.Loader.setConfig({
			enabled: true,
			disableCaching: false,
			paths: {
				'LogViewer' : './logviewer',
				'Ext.ux'    : '../../../extjs-4.1.1/ux'
			}
		});
	</script>
</head>
<body>
    
    <form id="exportform" method="post" target="exportframe">
        <input type="hidden" id="excelconfig" name="fileName" value="" />
    </form>
</body>
</html>

Server-side
On to the backend. I’m using Spring 3, so what I have here is pretty standard. I did create a model for the tree nodes. You’ll notice that there are a few of the NodeInterface fields in the model (for better or worse).
FileNode.java

package XXX.XXX.model;

import java.util.Date;
import java.util.List;

import org.codehaus.jackson.annotate.JsonAutoDetect;
import org.codehaus.jackson.map.annotate.JsonSerialize;

@JsonAutoDetect
@JsonSerialize(include = JsonSerialize.Inclusion.NON_NULL)
public class FileNode {
	
	private String text;
	public String getText() { return text; }
	public void setText(String text) { this.text = text; }
	
	private boolean leaf;
	public boolean isLeaf() { return leaf; }
	public void setLeaf(boolean leaf) { this.leaf = leaf; }
	
	private String fileName;
	public String getFileName() { return fileName; }
	public void setFileName(String fileName) { this.fileName = fileName; }
	
	private Object fileSize;
	public Object getFileSize() { return fileSize; }
	public void setFileSize(Object fileSize) { this.fileSize = fileSize; }

	private Date fileDate;
	public Date getFileDate() { return fileDate; }
	public void setFileDate(Date fileDate) { this.fileDate = fileDate; }
	
	private String filePath;
	public String getFilePath() { return filePath; }
	public void setFilePath(String filePath) { this.filePath = filePath; }
	
	private List<FileNode> children;
	public List<FileNode> getChildren() { return children; }
	public void setChildren(List<FileNode> children) { this.children = children; }

	@Override
	public String toString() {
		return "FileNode [leaf=" + leaf + ", fileName=" + fileName
				+ ", fileSize=" + fileSize + ", filePath=" + filePath + "]";
	}
}

LogViewerService.java
In my service layer I am using a property to know which directory to start getting my files. I am building the full tree in one go. The first enhancement I would put in is to load the subdirectories on-demand, but in my case I felt that it wouldn’t be too many files so I’m just going for it.

package XXX.XXX.service;

import java.io.File;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

import javax.annotation.PostConstruct;

import XXX.XXX.FileNode;
import XXX.XXX.util.PropertyConstants;
import XXX.XXX.util.MyProperties;

import org.apache.log4j.LogManager;
import org.apache.log4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class LogViewerService {
	
	protected final Logger log = LogManager.getLogger(getClass());
	private String logDir;
	private String logDirPathName;

	@Autowired private MyProperties myProperties;

	/**
	 * If the log directory changes, restart app in order to get the new property
	 * into this service.
	 */
	@PostConstruct
	public void init() {
		logDir = myProperties.getProperty(PropertyConstants.TOMCAT_LOG_DIR);
	}

	/**
	 * Get a list of all the files, including files in subdirectories.
	 * @return List of all files found in the log directory, including subdirectories
	 */
	public List<FileNode> getFiles()  {
		List<FileNode> fileList = new ArrayList<FileNode>();
		File dir = new File(logDir);
		logDirPathName = dir.getPath();
		listFilesInDirectory(dir, fileList);
		return fileList;
	}

	/**
	 * This builds up the list of the files, including in subdirectories.  This
	 * is recursive for each subdirectory found
	 * @param dir The directory currently finding files in
	 * @param currentDir The list to add all files found in the current directory
	 */
	protected void listFilesInDirectory(File dir, List<FileNode> currentDir) {
		File[] files = dir.listFiles();
		if (files != null) {
			for (File f : files) {
				if (f.isDirectory()) {
					List<FileNode> dirTree = new ArrayList<FileNode>();
					FileNode node = addDirNode(f);
					currentDir.add(node);
					listFilesInDirectory(f, dirTree);
					node.setChildren(dirTree);
				} else {
					FileNode node = addFileNode(f);
					currentDir.add(node);
				}
			}
		}
	}
	
	/**
	 * Create a file node.  File nodes indicate they are a leaf
	 * and include the file path.
	 * @param diskFile The file on disk
	 * @return FileNode, populated as a file node
	 */
	protected FileNode addFileNode(File diskFile) {
		FileNode node = new FileNode();
		String diskFileName = diskFile.getName();
		node.setFileName(diskFileName);
		node.setFileDate(new Date(diskFile.lastModified()));
		node.setLeaf(true);
		node.setFileSize(new Long(diskFile.length()));
		
		String parentName = diskFile.getPath();
		String filePath = parentName.substring(logDirPathName.length() + 1);
		node.setFilePath(filePath);
		
		return node;
	}
	
	/**
	 * Create a directory node.  Directory nodes do not have a file path and
	 * are <i>not</i> leafs.  Their file size is set to "dir"
	 * @param diskFile
	 * @return FileNode, populated as a directory node
	 */
	protected FileNode addDirNode(File diskFile) {
		FileNode node = new FileNode();
		String diskFileName = diskFile.getName();
		node.setFileName(diskFileName);
		node.setFileDate(new Date(diskFile.lastModified()));
		node.setFileSize("<dir>");
		return node;
	}
	

	/**
	 * Get the file from the log directory.
	 * @param fileName FileName includes path information if file is not in the root
	 * log directory.
	 * @return file found
	 */
	public File getFile(String fileName) {
		File file = new File(logDir + "\\" + fileName);
		return file;
	}

}

LogViewController.java
And finally the controller. Again, pretty straightforward. Springs FileCopyUtils was an easy way to stream the file back to the browser.

package XXX.XXX.web;

import java.io.File;
import java.io.FileInputStream;
import java.util.List;
import java.util.Map;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import XXX.XXX.model.FileNode;
import XXX.XXX.service.LogViewerService;

import org.apache.log4j.LogManager;
import org.apache.log4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.util.FileCopyUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
@RequestMapping("/logviewer")
public class LogViewerController {
	
	protected final Logger log = LogManager.getLogger(getClass());
	@Autowired private LogViewerService logViewerService;
	
	/**
	 * Build list of all files in the log directory
	 * @return JSON response with objects expected by a tree grid.
	 */
	@RequestMapping(value="/files")
	public @ResponseBody Object getFiles(HttpServletResponse response) {
		try {
			List<FileNode> files = logViewerService.getFiles();
			return files;
		} catch (Exception e) {
			log.error("Error loading files ", e);
			// This just returns a Map that will result in JSON {"success":"false", "message":"Unexpected Error"}
			Map<String, Object> errorMap = WebUtil.getModelMapMessage(false, ControllerConstants.DEFAULT_ERROR_MSG, null);
			return errorMap;
		}
	}
	
	/**
	 * Open a specified file in a browser window/tab.  Some of the success of the
	 * open might depend on client-side settings.  
	 * @param request
	 * @param response
	 * @param fileName The name of the file to open, including path info if not in
	 * the root log directory.
	 */
	@RequestMapping(value="/open")
	public void openFile(HttpServletRequest request, HttpServletResponse response,
			@RequestParam("fileName") String fileName ) {
		try {
			File file = logViewerService.getFile(fileName);
			byte[] content = new byte[(int)file.length()];
			response.setContentType("text/plain");
			response.addHeader("content-disposition", "inline;filename="+fileName);
			response.setContentLength(content.length);
			FileInputStream in = new FileInputStream(file);
			FileCopyUtils.copy(in, response.getOutputStream());
		} catch (Exception e) {
			log.error("Something happened", e);
			response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
		}
	}
	
	/**
	 * Download the selected file to the client.  
	 * @param request
	 * @param response
	 * @param fileName The name of the file to open, including path info if not in
	 * the root log directory.
	 */
	@RequestMapping(value="/download", method = RequestMethod.POST)
	public void downloadFile(HttpServletRequest request, HttpServletResponse response,
			@RequestParam("fileName") String fileName ) {
		try {
			File file = logViewerService.getFile(fileName);
			byte[] content = new byte[(int)file.length()];
			response.setContentType("application/octet-stream");
			response.setHeader("Content-Disposition", "attachment;filename="+fileName);
			response.setHeader("Cache-Control", "cache, must-revalidate");
			response.setHeader("Pragma", "public");
			response.setHeader("Content-Transfer-Encoding", "binary");
			response.setContentLength(content.length);
			FileInputStream in = new FileInputStream(file);
			FileCopyUtils.copy(in, response.getOutputStream());
			}
		} catch (Exception e) {
			log.error("Something happened", e);
			response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
		}
	}
}

And, before I forget, here is what the JSON should look like:

[
    {
        "fileName": "file1.log",
        "fileSize": 43897,
        "fileDate": "2009-06-17T16:31:49.123+0000",
		"filePath": "file1.log",
		"leaf": true
	}, {
		"fileName": "file2.log",
		"fileSize": 43897,
		"fileDate": "2009-06-17T16:31:49.123+0000",
		"filePath": "file2.log",
		"leaf": true
	}, {
		"fileName": "sc",
		"fileSize": "dir",
		"fileDate": "2009-06-17T16:31:49.123+0000",
        "children":[{
			"fileName": "file3.log",
			"fileSize": 43897,
			"fileDate": "2009-06-17T16:31:49.123+0000",
	        	"filePath": "sc/file3.log",
			"leaf": true
        }, {
			"fileName": "file4.log",
			"fileSize": 43897,
			"fileDate": "2009-06-17T16:31:49.123+0000",
	        	"filePath": "sc/file4.log",
			"leaf": true
        }]
	}
]

And because this post isn’t long enough, here is my wimpy unit test

package XXX.XXX.service;

import static org.junit.Assert.assertNotNull;
import static org.mockito.Mockito.when;

import java.util.List;

import XXX.XXX.model.FileNode;
import XXX.XXX.util.PropertyConstants;
import XXX.XXX.util.PumaProperties;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.runners.MockitoJUnitRunner;

@RunWith(MockitoJUnitRunner.class)
public class LogViewerServiceTest {
	
	@Mock PumaProperties mockPumaProperties;
	@InjectMocks LogViewerService logViewerService;
	
	@Before
	public void init() {
		when(mockPumaProperties.getProperty(PropertyConstants.TOMCAT_LOG_DIR))
			.thenReturn("put log directory here");
		
		logViewerService.init();
	}
	
	@Test
	public void testGetFiles() {
		List<FileNode> fileTree = logViewerService.getFiles();
		assertNotNull("fileTree populated", fileTree);
	}
}

One last screenshot of what it looks like when there is an error. Figuring out what to do when there was an error seemed like the hardest part of the tree panel.

File Browser with Error

And that truly is the file browser from top to bottom. Like I said, enhancement number one is loading the children nodes dynamically instead of fully populating the tree.

Some links I found helpful:

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s