Revision 21c57799
b/res/layout/connectionthrottle.xml | ||
---|---|---|
85 | 85 |
android:text="Seconds"></TextView> |
86 | 86 |
</LinearLayout> |
87 | 87 |
</LinearLayout> |
88 |
<LinearLayout android:layout_width="fill_parent" android:id="@+id/linearLayout3" android:gravity="center" android:layout_height="wrap_content" android:layout_marginTop="10dip"> |
|
88 |
<LinearLayout android:layout_width="fill_parent" android:id="@+id/linearLayout3" android:gravity="center" android:layout_height="wrap_content" android:layout_marginTop="10dip" android:layout_marginBottom="10dip">
|
|
89 | 89 |
<Button android:layout_height="wrap_content" android:layout_gravity="center" android:layout_width="120dip" android:layout_marginRight="5dip" android:id="@+id/enable_throttle_button" android:text="Disable Throttle" android:lines="2"></Button> |
90 | 90 |
<Button android:id="@+id/save_throttle_button" android:layout_height="wrap_content" android:layout_gravity="center" android:layout_width="120dip" android:layout_marginLeft="5dip" android:text="Save Throttle Settings"></Button> |
91 | 91 |
</LinearLayout> |
b/src/com/rackspacecloud/android/AccessControlActivity.java | ||
---|---|---|
136 | 136 |
setListAdapter(new NetworkItemAdapter()); |
137 | 137 |
} |
138 | 138 |
} |
139 |
|
|
140 |
private void displayLoadingCell() { |
|
141 |
String a[] = new String[1]; |
|
142 |
a[0] = "Loading..."; |
|
143 |
setListAdapter(new ArrayAdapter<String>(this, R.layout.loadingcell, R.id.loading_label, a)); |
|
144 |
getListView().setTextFilterEnabled(true); |
|
145 |
getListView().setDividerHeight(0); // hide the dividers so it won't look like a list row |
|
146 |
getListView().setItemsCanFocus(false); |
|
147 |
} |
|
139 | 148 |
|
140 | 149 |
private void displayNoRulesCell() { |
141 | 150 |
String a[] = new String[1]; |
... | ... | |
147 | 156 |
} |
148 | 157 |
|
149 | 158 |
private void loadNetworkItems() { |
159 |
displayLoadingCell(); |
|
150 | 160 |
new LoadNetworkItemsTask().execute((Void[]) null); |
151 | 161 |
} |
152 | 162 |
|
... | ... | |
187 | 197 |
|
188 | 198 |
@Override |
189 | 199 |
protected void onPreExecute(){ |
190 |
showDialog(); |
|
200 |
//set to null so will reload on config changes |
|
201 |
networkItems = null; |
|
191 | 202 |
} |
192 | 203 |
|
193 | 204 |
@Override |
... | ... | |
203 | 214 |
|
204 | 215 |
@Override |
205 | 216 |
protected void onPostExecute(ArrayList<NetworkItem> result) { |
206 |
hideDialog(); |
|
207 | 217 |
if (exception != null) { |
208 | 218 |
showAlert("Error", exception.getMessage()); |
209 | 219 |
} |
... | ... | |
214 | 224 |
private class DeleteNetworkItemTask extends AsyncTask<Void, Void, HttpBundle> { |
215 | 225 |
|
216 | 226 |
private CloudServersException exception; |
227 |
private NetworkItem networkItem; |
|
217 | 228 |
|
218 | 229 |
@Override |
219 | 230 |
//let user know their process has started |
220 | 231 |
protected void onPreExecute(){ |
221 |
showDialog(); |
|
232 |
networkItem = networkItems.get(lastSelectedRulePosition); |
|
233 |
displayLoadingCell(); |
|
234 |
//set to null so will reload on config change |
|
235 |
networkItems = null; |
|
222 | 236 |
} |
223 | 237 |
@Override |
224 | 238 |
protected HttpBundle doInBackground(Void... arg0) { |
225 | 239 |
HttpBundle bundle = null; |
226 | 240 |
try { |
227 |
bundle = new NetworkItemManager(getContext()).delete(loadBalancer, networkItems.get(lastSelectedRulePosition));
|
|
241 |
bundle = new NetworkItemManager(getContext()).delete(loadBalancer, networkItem); |
|
228 | 242 |
} catch (CloudServersException e) { |
229 | 243 |
exception = e; |
230 | 244 |
} |
... | ... | |
233 | 247 |
|
234 | 248 |
@Override |
235 | 249 |
protected void onPostExecute(HttpBundle bundle) { |
236 |
hideDialog(); |
|
237 | 250 |
HttpResponse response = bundle.getResponse(); |
238 | 251 |
if (response != null) { |
239 | 252 |
int statusCode = response.getStatusLine().getStatusCode(); |
b/src/com/rackspacecloud/android/AndroidCloudApplication.java | ||
---|---|---|
20 | 20 |
private boolean deletingObjectProcessing; |
21 | 21 |
private boolean deletingContainerProcessing; |
22 | 22 |
private boolean downloadingObject; |
23 |
private boolean isSettingLogs; |
|
24 |
private boolean isSettingSessionPersistence; |
|
23 | 25 |
private HttpEntity downloadedObject; |
24 | 26 |
private ArrayList<ContainerObjects> curDirFiles; |
25 | 27 |
|
... | ... | |
82 | 84 |
public boolean isLoggingIn(){ |
83 | 85 |
return isLoggingIn; |
84 | 86 |
} |
87 |
|
|
88 |
public void setIsSettingLogs(Boolean logging){ |
|
89 |
isSettingLogs = logging; |
|
90 |
} |
|
91 |
|
|
92 |
public boolean isSettingLogs(){ |
|
93 |
return isSettingLogs; |
|
94 |
} |
|
95 |
|
|
96 |
public void setSettingSessionPersistence(Boolean setting){ |
|
97 |
isSettingSessionPersistence = setting; |
|
98 |
} |
|
99 |
|
|
100 |
public boolean isSettingSessionPersistence(){ |
|
101 |
return isSettingSessionPersistence; |
|
102 |
} |
|
85 | 103 |
} |
b/src/com/rackspacecloud/android/CloudActivity.java | ||
---|---|---|
26 | 26 |
import android.content.Intent; |
27 | 27 |
import android.content.DialogInterface.OnCancelListener; |
28 | 28 |
import android.os.Bundle; |
29 |
import android.util.Log; |
|
29 | 30 |
import android.view.WindowManager; |
30 | 31 |
import android.view.ViewGroup.LayoutParams; |
31 | 32 |
import android.widget.ProgressBar; |
... | ... | |
160 | 161 |
|
161 | 162 |
protected final void hideDialog() { |
162 | 163 |
if(pDialog != null){ |
164 |
Log.d("info", "dialog hide"); |
|
163 | 165 |
isLoading = false; |
164 | 166 |
pDialog.dismiss(); |
165 | 167 |
} |
... | ... | |
167 | 169 |
|
168 | 170 |
protected final void showDialog() { |
169 | 171 |
if(pDialog == null || !pDialog.isShowing()){ |
172 |
Log.d("info", "dialog created"); |
|
170 | 173 |
isLoading = true; |
171 | 174 |
pDialog = new ProgressDialog(this); |
172 | 175 |
pDialog.setProgressStyle(R.style.NewDialog); |
b/src/com/rackspacecloud/android/CloudListActivity.java | ||
---|---|---|
26 | 26 |
import android.content.Intent; |
27 | 27 |
import android.content.DialogInterface.OnCancelListener; |
28 | 28 |
import android.os.Bundle; |
29 |
import android.util.Log; |
|
29 | 30 |
import android.view.WindowManager; |
30 | 31 |
import android.view.ViewGroup.LayoutParams; |
31 | 32 |
import android.widget.ProgressBar; |
... | ... | |
160 | 161 |
|
161 | 162 |
protected final void hideDialog() { |
162 | 163 |
if(pDialog != null){ |
164 |
Log.d("info", "dialog hide"); |
|
163 | 165 |
isLoading = false; |
164 | 166 |
pDialog.dismiss(); |
165 | 167 |
} |
... | ... | |
167 | 169 |
|
168 | 170 |
protected final void showDialog() { |
169 | 171 |
if(pDialog == null || !pDialog.isShowing()){ |
172 |
Log.d("info", "dialog created"); |
|
170 | 173 |
isLoading = true; |
171 | 174 |
pDialog = new ProgressDialog(this); |
172 | 175 |
pDialog.setProgressStyle(R.style.NewDialog); |
b/src/com/rackspacecloud/android/ConnectionThrottleActivity.java | ||
---|---|---|
43 | 43 |
outState.putSerializable("loadBalancer", loadBalancer); |
44 | 44 |
} |
45 | 45 |
|
46 |
|
|
46 |
|
|
47 | 47 |
protected void restoreState(Bundle state) { |
48 | 48 |
super.restoreState(state); |
49 | 49 |
|
50 | 50 |
if(state != null && state.containsKey("loadBalancer")){ |
51 | 51 |
loadBalancer = (LoadBalancer)state.getSerializable("loadBalancer"); |
52 | 52 |
} |
53 |
connectionThrottle = loadBalancer.getConnectionThrottle(); |
|
53 | 54 |
minCons = (EditText)findViewById(R.id.min_connections_text); |
54 | 55 |
maxCons = (EditText)findViewById(R.id.max_connections_text); |
55 | 56 |
maxConRate = (EditText)findViewById(R.id.max_connection_rate); |
... | ... | |
76 | 77 |
connectionThrottle.setMaxConnections("100"); |
77 | 78 |
connectionThrottle.setMaxConnectionRate("25"); |
78 | 79 |
connectionThrottle.setRateInterval("5"); |
79 |
|
|
80 |
|
|
80 | 81 |
loadBalancer.setConnectionThrottle(connectionThrottle); |
81 | 82 |
//Turn on EditTexts |
82 | 83 |
minCons.setEnabled(true); |
... | ... | |
115 | 116 |
new UpdateConnectionThrottleTask().execute(); |
116 | 117 |
} |
117 | 118 |
} else { |
118 |
//if there was no connection throttle before |
|
119 |
//then no need to delete it |
|
120 |
if(loadBalancer.getConnectionThrottle() != null){ |
|
121 |
new DeleteConnectionThrottleTask().execute(); |
|
122 |
} else { |
|
123 |
finish(); |
|
124 |
} |
|
119 |
new DeleteConnectionThrottleTask().execute(); |
|
125 | 120 |
} |
126 | 121 |
} |
127 | 122 |
}); |
... | ... | |
165 | 160 |
} else { |
166 | 161 |
try { |
167 | 162 |
int value = Integer.parseInt(result); |
168 |
Log.d("info", min + " <= " + value + " <= " + max); |
|
169 | 163 |
if(value >= min && value <= max){ |
170 | 164 |
return true; |
171 | 165 |
} else { |
b/src/com/rackspacecloud/android/ViewLoadBalancerActivity.java | ||
---|---|---|
47 | 47 |
|
48 | 48 |
private LoadBalancer loadBalancer; |
49 | 49 |
private PollLoadBalancerTask pollLoadBalancerTask; |
50 |
private AndroidCloudApplication app; |
|
51 |
private LoggingListenerTask loggingListenerTask; |
|
52 |
private SessionPersistenceListenerTask sessionPersistenceListenerTask; |
|
50 | 53 |
|
51 | 54 |
@Override |
52 | 55 |
public void onCreate(Bundle savedInstanceState) { |
53 | 56 |
super.onCreate(savedInstanceState); |
54 | 57 |
loadBalancer = (LoadBalancer) this.getIntent().getExtras().get("loadBalancer"); |
55 | 58 |
setContentView(R.layout.view_loadbalancer); |
59 |
app = (AndroidCloudApplication)this.getApplication(); |
|
56 | 60 |
restoreState(savedInstanceState); |
57 | 61 |
} |
58 | 62 |
|
... | ... | |
64 | 68 |
|
65 | 69 |
protected void restoreState(Bundle state) { |
66 | 70 |
super.restoreState(state); |
67 |
|
|
71 |
|
|
68 | 72 |
if (state != null && state.containsKey("loadBalancer") && state.getSerializable("loadBalancer") != null) { |
69 | 73 |
loadBalancer = (LoadBalancer) state.getSerializable("loadBalancer"); |
70 | 74 |
loadLoadBalancerData(); |
... | ... | |
73 | 77 |
else{ |
74 | 78 |
new LoadLoadBalancerTask().execute((Void[]) null); |
75 | 79 |
} |
80 |
|
|
81 |
/* |
|
82 |
* if is setting logs we need another listener |
|
83 |
*/ |
|
84 |
if(app.isSettingLogs()){ |
|
85 |
loggingListenerTask = new LoggingListenerTask(); |
|
86 |
loggingListenerTask.execute(); |
|
87 |
} |
|
88 |
|
|
89 |
if(app.isSettingSessionPersistence()){ |
|
90 |
sessionPersistenceListenerTask = new SessionPersistenceListenerTask(); |
|
91 |
sessionPersistenceListenerTask.execute(); |
|
92 |
} |
|
76 | 93 |
} |
77 | 94 |
|
78 | 95 |
@Override |
... | ... | |
86 | 103 |
} |
87 | 104 |
} |
88 | 105 |
|
106 |
@Override |
|
107 |
protected void onStop(){ |
|
108 |
super.onStop(); |
|
109 |
|
|
110 |
/* |
|
111 |
* Need to stop running listener task |
|
112 |
* if we exit |
|
113 |
*/ |
|
114 |
if(loggingListenerTask != null){ |
|
115 |
loggingListenerTask.cancel(true); |
|
116 |
} |
|
117 |
|
|
118 |
if(sessionPersistenceListenerTask != null){ |
|
119 |
sessionPersistenceListenerTask.cancel(true); |
|
120 |
} |
|
121 |
} |
|
122 |
|
|
89 | 123 |
private void setupButton(int resourceId, OnClickListener onClickListener) { |
90 | 124 |
Button button = (Button) findViewById(resourceId); |
91 | 125 |
button.setOnClickListener(onClickListener); |
... | ... | |
275 | 309 |
//Need to show different message depending on the state |
276 | 310 |
//of connection_logs/session_persistence |
277 | 311 |
protected void onPrepareDialog(int id, Dialog dialog){ |
278 |
switch (id) { |
|
279 |
case R.id.connection_log_button: |
|
280 |
String logTitle; |
|
281 |
String logMessage; |
|
282 |
String logButton; |
|
283 |
if(loadBalancer.getIsConnectionLoggingEnabled().equals("true")){ |
|
284 |
logTitle = "Disable Logs"; |
|
285 |
logMessage = "Are you sure you want to disable logs for this Load Balancer?"; |
|
286 |
logButton = "Disable"; |
|
287 |
} else { |
|
288 |
logTitle = "Enable Logs"; |
|
289 |
logMessage = "Log files will be processed every hour and stored in your Cloud Files account. " + |
|
290 |
"Standard Cloud Files storage and transfer fees will be accessed for the use of this feature." + |
|
291 |
"\n\nAre you sure you want to enable logs for this Load Balancer?"; |
|
292 |
logButton = "Enable"; |
|
293 |
} |
|
294 |
((AlertDialog)dialog).setTitle(logTitle); |
|
295 |
((AlertDialog)dialog).setMessage(logMessage); |
|
296 |
Button sessionLogButton = ((AlertDialog)dialog).getButton(AlertDialog.BUTTON1); |
|
297 |
sessionLogButton.setText(logButton); |
|
298 |
sessionLogButton.invalidate(); |
|
299 |
break; |
|
300 |
case R.id.session_persistence_button: |
|
301 |
String sessionMessage; |
|
302 |
String sessionButton; |
|
303 |
if(loadBalancer.getSessionPersistence() != null){ |
|
304 |
Log.d("info", "in sessionpersistence != null"); |
|
305 |
sessionMessage = "Are you sure you want to disable session persistence for this Load Balancer?"; |
|
306 |
sessionButton = "Disable"; |
|
307 |
} else { |
|
308 |
Log.d("info", "in sessionpersistence == null"); |
|
309 |
sessionMessage = "Are you sure you want to enable session persistence for this Load Balancer?"; |
|
310 |
sessionButton = "Enable"; |
|
312 |
if(loadBalancer != null){ |
|
313 |
switch (id) { |
|
314 |
case R.id.connection_log_button: |
|
315 |
String logTitle; |
|
316 |
String logMessage; |
|
317 |
String logButton; |
|
318 |
if(loadBalancer.getIsConnectionLoggingEnabled().equals("true")){ |
|
319 |
logTitle = "Disable Logs"; |
|
320 |
logMessage = "Are you sure you want to disable logs for this Load Balancer?"; |
|
321 |
logButton = "Disable"; |
|
322 |
} else { |
|
323 |
logTitle = "Enable Logs"; |
|
324 |
logMessage = "Log files will be processed every hour and stored in your Cloud Files account. " + |
|
325 |
"Standard Cloud Files storage and transfer fees will be accessed for the use of this feature." + |
|
326 |
"\n\nAre you sure you want to enable logs for this Load Balancer?"; |
|
327 |
logButton = "Enable"; |
|
328 |
} |
|
329 |
((AlertDialog)dialog).setTitle(logTitle); |
|
330 |
((AlertDialog)dialog).setMessage(logMessage); |
|
331 |
Button sessionLogButton = ((AlertDialog)dialog).getButton(AlertDialog.BUTTON1); |
|
332 |
sessionLogButton.setText(logButton); |
|
333 |
sessionLogButton.invalidate(); |
|
334 |
break; |
|
335 |
case R.id.session_persistence_button: |
|
336 |
String sessionMessage; |
|
337 |
String sessionButton; |
|
338 |
if(loadBalancer.getSessionPersistence() != null){ |
|
339 |
Log.d("info", "in sessionpersistence != null"); |
|
340 |
sessionMessage = "Are you sure you want to disable session persistence for this Load Balancer?"; |
|
341 |
sessionButton = "Disable"; |
|
342 |
} else { |
|
343 |
Log.d("info", "in sessionpersistence == null"); |
|
344 |
sessionMessage = "Are you sure you want to enable session persistence for this Load Balancer?"; |
|
345 |
sessionButton = "Enable"; |
|
346 |
} |
|
347 |
((AlertDialog)dialog).setMessage(sessionMessage); |
|
348 |
Button sessionPersistButton = ((AlertDialog)dialog).getButton(AlertDialog.BUTTON1); |
|
349 |
sessionPersistButton.setText(sessionButton); |
|
350 |
sessionPersistButton.invalidate(); |
|
351 |
break; |
|
311 | 352 |
} |
312 |
((AlertDialog)dialog).setMessage(sessionMessage); |
|
313 |
Button sessionPersistButton = ((AlertDialog)dialog).getButton(AlertDialog.BUTTON1); |
|
314 |
sessionPersistButton.setText(sessionButton); |
|
315 |
sessionPersistButton.invalidate(); |
|
316 |
break; |
|
317 | 353 |
} |
318 | 354 |
} |
319 | 355 |
|
... | ... | |
596 | 632 |
|
597 | 633 |
@Override |
598 | 634 |
protected void onPreExecute(){ |
599 |
showDialog(); |
|
635 |
//showDialog(); |
|
636 |
app.setIsSettingLogs(true); |
|
637 |
loggingListenerTask = new LoggingListenerTask(); |
|
638 |
loggingListenerTask.execute(); |
|
600 | 639 |
} |
601 | 640 |
|
602 | 641 |
@Override |
... | ... | |
612 | 651 |
|
613 | 652 |
@Override |
614 | 653 |
protected void onPostExecute(HttpBundle bundle) { |
615 |
hideDialog(); |
|
654 |
//hideDialog(); |
|
655 |
app.setIsSettingLogs(false); |
|
616 | 656 |
HttpResponse response = bundle.getResponse(); |
617 | 657 |
if (response != null) { |
618 | 658 |
int statusCode = response.getStatusLine().getStatusCode(); |
619 | 659 |
if (statusCode == 202 || statusCode == 204) { |
620 |
pollLoadBalancerTask = new PollLoadBalancerTask(); |
|
621 |
pollLoadBalancerTask.execute((Void[]) null); |
|
660 |
if(Boolean.valueOf(loadBalancer.getIsConnectionLoggingEnabled())){ |
|
661 |
showToast("Logging has been disabled"); |
|
662 |
} else { |
|
663 |
showToast("Logging has been enabled"); |
|
664 |
} |
|
622 | 665 |
} else { |
623 | 666 |
CloudServersException cse = parseCloudServersException(response); |
624 | 667 |
if ("".equals(cse.getMessage())) { |
... | ... | |
635 | 678 |
} |
636 | 679 |
} |
637 | 680 |
|
681 |
/* |
|
682 |
* listens for the application to change isSettingLogs |
|
683 |
* listens so activity knows when it should display |
|
684 |
* the new settings |
|
685 |
*/ |
|
686 |
private class LoggingListenerTask extends |
|
687 |
AsyncTask<Void, Void, Void> { |
|
688 |
|
|
689 |
@Override |
|
690 |
protected Void doInBackground(Void... arg1) { |
|
691 |
|
|
692 |
while(app.isSettingLogs()){ |
|
693 |
// wait for process to finish |
|
694 |
// or have it be canceled |
|
695 |
if(loggingListenerTask.isCancelled()){ |
|
696 |
return null; |
|
697 |
} |
|
698 |
} |
|
699 |
return null; |
|
700 |
} |
|
701 |
|
|
702 |
/* |
|
703 |
* when no longer processing, time to load |
|
704 |
* the new files |
|
705 |
*/ |
|
706 |
@Override |
|
707 |
protected void onPostExecute(Void arg1) { |
|
708 |
pollLoadBalancerTask = new PollLoadBalancerTask(); |
|
709 |
pollLoadBalancerTask.execute((Void[]) null); |
|
710 |
} |
|
711 |
} |
|
712 |
|
|
638 | 713 |
private class SessionPersistenceTask extends AsyncTask<Void, Void, HttpBundle> { |
639 | 714 |
|
640 | 715 |
private CloudServersException exception; |
641 |
|
|
716 |
|
|
642 | 717 |
@Override |
643 | 718 |
protected void onPreExecute(){ |
644 |
showDialog(); |
|
719 |
app.setSettingSessionPersistence(true); |
|
720 |
sessionPersistenceListenerTask = new SessionPersistenceListenerTask(); |
|
721 |
sessionPersistenceListenerTask.execute(); |
|
645 | 722 |
} |
646 | 723 |
|
647 | 724 |
@Override |
... | ... | |
659 | 736 |
} |
660 | 737 |
return bundle; |
661 | 738 |
} |
662 |
|
|
739 |
|
|
663 | 740 |
@Override |
664 | 741 |
protected void onPostExecute(HttpBundle bundle) { |
665 |
hideDialog(); |
|
742 |
//hideDialog(); |
|
743 |
app.setSettingSessionPersistence(false); |
|
666 | 744 |
HttpResponse response = bundle.getResponse(); |
667 | 745 |
if (response != null) { |
668 | 746 |
int statusCode = response.getStatusLine().getStatusCode(); |
669 | 747 |
if (statusCode == 202 || statusCode == 200) { |
748 |
if(loadBalancer.getSessionPersistence() != null){ |
|
749 |
showToast("Session Persistence has been disabled"); |
|
750 |
} else { |
|
751 |
showToast("Session Persistence has been enabled"); |
|
752 |
} |
|
670 | 753 |
pollLoadBalancerTask = new PollLoadBalancerTask(); |
671 | 754 |
pollLoadBalancerTask.execute((Void[]) null); |
672 | 755 |
} else { |
... | ... | |
684 | 767 |
|
685 | 768 |
} |
686 | 769 |
} |
770 |
|
|
771 |
/* |
|
772 |
* listens for the application to change isSettingSessionPersistence |
|
773 |
* listens so activity knows when it should display |
|
774 |
* the new settings |
|
775 |
*/ |
|
776 |
private class SessionPersistenceListenerTask extends |
|
777 |
AsyncTask<Void, Void, Void> { |
|
778 |
|
|
779 |
@Override |
|
780 |
protected Void doInBackground(Void... arg1) { |
|
781 |
|
|
782 |
while(app.isSettingSessionPersistence()){ |
|
783 |
// wait for process to finish |
|
784 |
// or have it be canceled |
|
785 |
if(sessionPersistenceListenerTask.isCancelled()){ |
|
786 |
return null; |
|
787 |
} |
|
788 |
} |
|
789 |
return null; |
|
790 |
} |
|
791 |
|
|
792 |
/* |
|
793 |
* when no longer processing, time to load |
|
794 |
* the new files |
|
795 |
*/ |
|
796 |
@Override |
|
797 |
protected void onPostExecute(Void arg1) { |
|
798 |
pollLoadBalancerTask = new PollLoadBalancerTask(); |
|
799 |
pollLoadBalancerTask.execute((Void[]) null); |
|
800 |
} |
|
801 |
} |
|
687 | 802 |
|
688 | 803 |
|
689 | 804 |
} |
Also available in: Unified diff